{"id":51579142,"url":"https://github.com/otezz/zellij-vtabs","last_synced_at":"2026-07-11T04:01:38.832Z","repository":{"id":369884286,"uuid":"1291239225","full_name":"otezz/zellij-vtabs","owner":"otezz","description":"Personal Zellij vertical grouped-tab sidebar with Claude Code attention indicators","archived":false,"fork":false,"pushed_at":"2026-07-07T10:30:38.000Z","size":114,"stargazers_count":1,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-07-07T11:09:12.562Z","etag":null,"topics":["rust","terminal","tui","wasm","zellij","zellij-plugin"],"latest_commit_sha":null,"homepage":"https://otezz.github.io/zellij-vtabs/","language":"Rust","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/otezz.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-07-06T16:15:36.000Z","updated_at":"2026-07-07T10:30:45.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/otezz/zellij-vtabs","commit_stats":null,"previous_names":["otezz/zellij-vtabs"],"tags_count":4,"template":false,"template_full_name":null,"purl":"pkg:github/otezz/zellij-vtabs","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/otezz%2Fzellij-vtabs","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/otezz%2Fzellij-vtabs/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/otezz%2Fzellij-vtabs/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/otezz%2Fzellij-vtabs/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/otezz","download_url":"https://codeload.github.com/otezz/zellij-vtabs/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/otezz%2Fzellij-vtabs/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35350133,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-07-11T02:00:05.354Z","response_time":104,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["rust","terminal","tui","wasm","zellij","zellij-plugin"],"created_at":"2026-07-11T04:01:34.990Z","updated_at":"2026-07-11T04:01:38.825Z","avatar_url":"https://github.com/otezz.png","language":"Rust","funding_links":[],"categories":[],"sub_categories":[],"readme":"# zellij-vtabs\n\nA vertical, grouped, collapsible tab sidebar for [Zellij](https://zellij.dev) — with attention indicators driven by Claude Code.\n\n\u003e ⚠️ **This is a personal tool, built for my own setup — not a general-purpose plugin.**\n\u003e It hardcodes assumptions about *my* workflow: my plugin path, my `:`-based tab-naming\n\u003e convention, my Zellij version, my Claude Code hooks. It is published for my own reuse\n\u003e across machines, not as something meant to work out-of-the-box for anyone else. There\n\u003e are no stability guarantees, no issue tracker, and no intention to generalize it. If you\n\u003e stumbled on this: feel free to read/fork, but expect to change paths and conventions to\n\u003e fit your own environment.\n\n## What it does\n\nZellij has no native vertical tabs. This plugin renders a left sidebar showing tabs as a\n**collapsible tree grouped by name prefix**, so I can keep many tabs organized at a glance:\n\n![zellij-vtabs sidebar](docs/screenshot.png)\n\nGroups (`▼`/`▶`) collapse and expand; `◆` (yellow) marks a tab needing input, `✓` (green) a\nfinished one, and `●` marks the active tab.\n\n- **Grouping** — a tab named `group:label` goes under group **group** with label **label**;\n  a tab with no `:` lands in **General**. (First `:` wins.)\n- **Collapse / expand** groups (`▶`/`▼`).\n- **Reorder groups and tabs** — `Shift+J`/`Shift+K` (or `Shift+↓`/`Shift+↑`) move the\n  selected row: a group header moves the whole group, a tab moves within its group. Tab\n  moves are *virtual* (display-order only — the plugin API can't move real tabs): a group\n  follows Zellij's native tab positions until you explicitly reorder in it, after which the\n  saved order wins for that group.\n- **Persistent state** — group order, tab order, and collapse state survive session\n  restarts and stay in sync across every tab's sidebar (stored per session in the plugin's\n  cache dir).\n- **Rename inline** — `r` opens an inline editor (`Enter` commits, `Esc` cancels): on a\n  group header it renames the group (every member tab is re-prefixed, saved state\n  follows); on a tab row it renames the tab's label, keeping its group.\n- **Auto-grouping (opt-in)** — a small shell script pipes each pane's starting directory\n  (+ git facts) to the plugin, which auto-names still-default tabs (`repo:branch`,\n  `repo:worktree`, …) by configurable rules. Manual names always win. See below.\n- **Navigate** with `j`/`k`/arrows, `Enter`/`Space` to switch tab or toggle a group.\n- **Mouse**: left-click to switch/toggle, scroll to move the selection.\n- **Active tab** marked with `●`; the selection highlight follows it.\n- **Attention icons** — `◆` (yellow, needs input) and `✓` (green, done), appearing on the\n  tab and **rolling up to a collapsed group's header**. Cleared automatically when I focus\n  the tab. Wired to Claude Code's `Notification`/`Stop` hooks.\n- **Working spinner** — an animated cyan spinner while Claude is running in a tab (wired\n  to the `UserPromptSubmit` hook). Unlike the attention icons it shows on the active tab\n  too and is *not* cleared by focusing — it ends when Claude needs input, finishes, or\n  exits (`SessionEnd`). Rolls up to collapsed group headers (waiting \u003e working \u003e done).\n\n## Requirements\n\n- **Zellij ≥ the `zellij-tile` version the wasm was built with** — release builds use\n  `0.44.2`, and the prebuilt wasm is verified working on Zellij **0.43.1, 0.44.2, and\n  0.44.3** (the protobuf plugin API tolerates version skew in both directions better than\n  Zellij's error messages suggest; a plugin built against a *newer* `zellij-tile` than the\n  running Zellij is the combination that reliably fails).\n- **Building from source:** Rust + `cargo` with the `wasm32-wasip1` target\n  (`rustup target add wasm32-wasip1`), and keep the `zellij-tile` pin **at or below** your\n  Zellij version — don't use a caret/range requirement, Cargo resolves those upward.\n\n## Quick try (zero install)\n\nZellij can load the plugin straight from the release URL — no download, no Rust. Save\nthis as `try-vtabs.kdl`:\n\n```kdl\nlayout {\n    pane split_direction=\"vertical\" {\n        pane size=28 borderless=true {\n            plugin location=\"https://github.com/otezz/zellij-vtabs/releases/latest/download/zellij-vtabs.wasm\"\n        }\n        pane\n    }\n    pane size=1 borderless=true {\n        plugin location=\"zellij:status-bar\"\n    }\n}\n```\n\nPre-seed the permissions in `~/.cache/zellij/permissions.kdl` (the in-pane grant prompt\ndoesn't render usably in a 28-column sidebar):\n\n```kdl\n\"https://github.com/otezz/zellij-vtabs/releases/latest/download/zellij-vtabs.wasm\" {\n    ReadApplicationState\n    ChangeApplicationState\n    ReadCliPipes\n}\n```\n\nThen: `zellij --new-session-with-layout ./try-vtabs.kdl`. Zellij caches the download, so\nthis also survives offline use. Name a couple of tabs `group:label` and you have the tree.\n\n## Build \u0026 install\n\n```bash\ncargo build --release --target wasm32-wasip1\ncp target/wasm32-wasip1/release/zellij-vtabs.wasm ~/.config/zellij/plugins/zellij-vtabs.wasm\n```\n\nOr skip the Rust toolchain and grab the prebuilt `zellij-vtabs.wasm` from the\n[latest release](https://github.com/otezz/zellij-vtabs/releases/latest):\n\n```bash\ncurl -Lo ~/.config/zellij/plugins/zellij-vtabs.wasm \\\n  https://github.com/otezz/zellij-vtabs/releases/latest/download/zellij-vtabs.wasm\n```\n\nPre-seed the plugin's permissions (Zellij's in-pane grant prompt doesn't render usably in a\nnarrow sidebar), in `~/.cache/zellij/permissions.kdl`. Note Zellij keys this by the **resolved\nabsolute** path, so use your real home dir here (not `~`):\n\n```kdl\n\"/home/\u003cyou\u003e/.config/zellij/plugins/zellij-vtabs.wasm\" {\n    ReadApplicationState\n    ChangeApplicationState\n    ReadCliPipes\n}\n```\n\nUse the layout in `layouts/vtabs.kdl` (also copied to `~/.config/zellij/layouts/vtabs.kdl`),\nand set it as the default in `~/.config/zellij/config.kdl`:\n\n```kdl\ndefault_layout \"vtabs\"\n```\n\nFast dev loop (code-only changes — layout/config changes still need a fresh session):\n\n```bash\ncargo build --release --target wasm32-wasip1 \\\n  \u0026\u0026 cp target/wasm32-wasip1/release/zellij-vtabs.wasm ~/.config/zellij/plugins/zellij-vtabs.wasm\nzellij action start-or-reload-plugin file:~/.config/zellij/plugins/zellij-vtabs.wasm\n```\n\n## Claude Code integration\n\nThe attention icons and working spinner are driven by Claude Code hooks. Rather than each\nhook piping a fixed signal, they call `shell/vtabs-work.sh` with an event name; the script\nkeeps a per-pane count of the main-agent turn plus outstanding subagents and derives the\nright signal — so the spinner stays lit for the *whole* task, not just the main agent's\nfirst turn. Install it and wire the hooks:\n\n```bash\ncp shell/vtabs-work.sh ~/.config/zellij/vtabs-work.sh \u0026\u0026 chmod +x ~/.config/zellij/vtabs-work.sh\n```\n\n```json\n{\n  \"hooks\": {\n    \"UserPromptSubmit\": [{ \"hooks\": [{ \"type\": \"command\", \"timeout\": 3,\n      \"command\": \"(~/.config/zellij/vtabs-work.sh prompt \u003e/dev/null 2\u003e\u00261 \u0026)\" }] }],\n    \"SubagentStart\": [{ \"hooks\": [{ \"type\": \"command\", \"timeout\": 3,\n      \"command\": \"(~/.config/zellij/vtabs-work.sh subagent-start \u003e/dev/null 2\u003e\u00261 \u0026)\" }] }],\n    \"SubagentStop\": [{ \"hooks\": [{ \"type\": \"command\", \"timeout\": 3,\n      \"command\": \"(~/.config/zellij/vtabs-work.sh subagent-stop \u003e/dev/null 2\u003e\u00261 \u0026)\" }] }],\n    \"Notification\": [{ \"hooks\": [{ \"type\": \"command\", \"timeout\": 3,\n      \"command\": \"(~/.config/zellij/vtabs-work.sh notify \u003e/dev/null 2\u003e\u00261 \u0026)\" }] }],\n    \"Stop\": [{ \"hooks\": [{ \"type\": \"command\", \"timeout\": 3,\n      \"command\": \"(~/.config/zellij/vtabs-work.sh stop \u003e/dev/null 2\u003e\u00261 \u0026)\" }] }],\n    \"SessionEnd\": [{ \"hooks\": [{ \"type\": \"command\", \"timeout\": 3,\n      \"command\": \"(~/.config/zellij/vtabs-work.sh end \u003e/dev/null 2\u003e\u00261 \u0026)\" }] }]\n  }\n}\n```\n\n- `UserPromptSubmit` (new turn) → start fresh, animated spinner on that tab\n- `SubagentStart` / `SubagentStop` → adjust the outstanding-subagent count (spinner stays lit)\n- `Notification` (needs input) → `◆` on that tab\n- `Stop` (main-agent turn ended) → `✓` **only if no subagents are still outstanding**,\n  otherwise the spinner keeps running — this is the whole reason for the counter: `Stop`\n  fires once per *main-agent* turn, so without it the spinner would clear while background\n  subagents are still working\n- `SessionEnd` (Claude exits) → clears any leftover spinner and the pane's count\n- Focusing the tab clears `◆`/`✓`; the spinner survives focus and ends via the hooks above\n\nThe script also plays a freedesktop sound on exactly those two real events — `complete`\nwhen the whole task finishes and `message-new-instant` when it needs input — gated by the\nsame counter, so you get one chime per task rather than one per main-agent turn or subagent.\nIt falls back `canberra-gtk-play` → `pw-play` → silent, so it's a no-op where neither exists.\nDelete the `sound=` / playback lines in `vtabs-work.sh` to turn it off.\n\nWhy the script (rather than a fixed pipe per hook, and why a shell counter rather than one\nin the plugin): the plugin runs one instance per tab and pipe delivery across instances\nisn't reliable, so a counter in plugin memory would diverge — the shell gives one per-pane\nstate file guarded by `flock`. Inside the script every `zellij pipe` still uses\n`\u003c /dev/null` (without it the pipe reads Claude's hook stdin until EOF and deadlocks) and\n`timeout 3` (which actually kills the pipe when no plugin is listening — a session without\nthis layout — since the hook-level `\"timeout\"` only stops Claude *waiting*). The `( … \u0026 )`\nsubshell makes each hook return instantly. (Shell backgrounding is fine; Claude Code's\n`async: true` hook property is not — async hooks failed to deliver pipes reliably in testing.)\n\nManual test — note it must target a **non-active** tab (the plugin never marks the tab you're\ncurrently on, by design):\n\n```bash\n# on tab B:\necho $ZELLIJ_PANE_ID        # e.g. 3\n# switch to another tab, then:\nzellij pipe --name \"zellij-vtabs::waiting::3\"\n```\n\n## Auto-grouping (opt-in)\n\nThe plugin can name default-named (`Tab #N`) tabs from each pane's starting directory.\nPlugins can't see pane cwds (WASI sandbox), so a small script pipes the facts in:\n\n```bash\ncp shell/vtabs-rename.sh ~/.config/zellij/vtabs-rename.sh\nchmod +x ~/.config/zellij/vtabs-rename.sh\n```\n\nRun it once per shell start — e.g. in `~/.zshrc`:\n\n```zsh\n(~/.config/zellij/vtabs-rename.sh \u0026) 2\u003e/dev/null\n```\n\nThen enable it in the layout's `plugin` block:\n\n```kdl\nautogroup_default \"repo\"                      // repo | dir | off (default: off)\nautogroup_1 \"/mnt/d/codes/work/** -\u003e work\"    // optional cwd-glob overrides, tried in order\n```\n\n- `repo` — group = the owning git repo's name, worktree-aware: a pane in a linked worktree\n  gets `repo:worktree-dir`; at the repo root the label is the branch; in a subdir, the dir\n  name. Non-repo dirs are left alone.\n- `dir` — plain `basename $cwd` (lands in **General**) when no rule matches.\n- Only tabs still named `Tab #N` are renamed — name a tab manually and it stays yours.\n\n### Claude Code worktrees\n\nTo have `claude -w fix-auth` label its tab with the worktree name (`repo:fix-auth`), add a\n`SessionStart` hook — `force` mode deliberately overrides the tab's current name:\n\n```json\n\"SessionStart\": [{ \"hooks\": [{ \"type\": \"command\", \"timeout\": 5,\n  \"command\": \"(~/.config/zellij/vtabs-rename.sh force \u003e/dev/null 2\u003e\u00261 \u0026)\" }] }]\n```\n\n### Worktree launcher\n\n`claude -w` names the worktree for you. To create one with **your own name**, in a new tab\ngrouped under the repo and already running Claude, use the `nw` / `rw` shell functions:\n\n```bash\ncp shell/vtabs-worktree.zsh ~/.config/zellij/vtabs-worktree.zsh\ncp layouts/vtabs-claude.kdl ~/.config/zellij/layouts/vtabs-claude.kdl   # a vtabs layout whose main pane runs `claude`\n# in ~/.zshrc:\nsource ~/.config/zellij/vtabs-worktree.zsh\n```\n\n- `nw fix-auth` → worktree at `\u003crepo\u003e/.claude/worktrees/fix-auth` on a new branch `fix-auth`,\n  opened in a new tab named `\u003crepo\u003e:fix-auth` (so it lands in the repo's group) running Claude.\n  `nw fix-auth main` branches off `main`. Run it from anywhere inside the repo.\n- `rw` (from inside a worktree tab) removes that worktree and closes the tab; `rw -f` forces\n  past uncommitted changes. It refuses to touch the main worktree.\n\n## Configuration\n\nOptional plugin config in the layout's `plugin` block (defaults shown):\n\n```kdl\nplugin location=\"file:~/.config/zellij/plugins/zellij-vtabs.wasm\" {\n    separator \":\"          // tab-name group separator\n    waiting_icon \"◆\"       // rendered yellow\n    completed_icon \"✓\"     // rendered green\n    spinner \"⣾⣽⣻⢿⡿⣟⣯⣷\"    // working animation: each char = one frame\n    // plus the autogroup_* keys described above\n}\n```\n\n### Spinner\n\n![working spinner](docs/spinner.svg)\n\nCompare animation candidates on the **[live preview page](https://otezz.github.io/zellij-vtabs/spinner.html)**\n(every set spinning inside a mock sidebar row), copy the frames you like, and set them via\nthe `spinner` config key. Width-1 glyphs only — emoji frames are double-width and break\nthe sidebar's column math.\n\n## Architecture notes (why it's built this way)\n\nThe one non-obvious design decision, learned the hard way:\n\n**Zellij spawns one plugin instance per tab, and per-instance mutable state always diverges** —\nbroadcast CLI pipes and `pipe_message_to_plugin` don't reliably fan out to every instance, and\n`Event::Visible` / `PaneUpdate.is_focused` aren't usable signals here (`is_focused` came through\nas `None`). The *only* state every instance reads identically is the **tab name** (via\n`TabUpdate`). So attention is encoded as a name suffix (` ⏳`/` ✅`) applied with `rename_tab`\n(a global mutation), then parsed back out for display.\n\nThe working model, which avoids any set/clear race:\n\n- **Set** marks a tab *only if it isn't the active tab* (you don't need a cue for what you're\n  looking at).\n- **Clear** strips the marker from the active tab on every `TabUpdate` (switching to a tab makes\n  it active → it's \"seen\" → cleared).\n\nGroup order and collapse state follow the same \"only global state survives\" rule: they live\nin a small file under the plugin's `/cache` mount (host side:\n`~/.cache/zellij/\u003cplugin-location\u003e/plugin_cache/`), which Zellij keys by plugin *location* —\nso every per-tab instance reads the same file. One file **per session** (named from\n`ModeUpdate`'s `session_name`), because a single shared file would let each session's save\nwipe the others' groups. Instances re-read the file on every `TabUpdate`, and only the\nfocused (visible) instance ever writes, so the sidebar you're looking at is always fresh.\n\nTwo build gotchas on modern Rust + Zellij:\n\n1. **Pin `zellij-tile` exactly, at or below the oldest Zellij you target** (`= 0.44.2`). A\n   caret range grabs the newest patch, and a plugin built against a newer `zellij-tile`\n   than the running Zellij fails to load (`could not find exported function`). The reverse\n   direction is fine — an older-tile build runs on newer Zellij.\n2. **Build as a binary crate, not `cdylib`.** On current Rust, a `cdylib` for `wasm32-wasip1`\n   emits a WASI *reactor* (no `_start`); Zellij needs a *command* (`_start`). A bin crate lets\n   `register_plugin!`'s own `main` become `_start`. (Don't add your own `fn main` — the macro\n   defines one.)\n\n## Layout / status bar\n\n`layouts/vtabs.kdl` puts the 28-col sidebar left of the main pane, with Zellij's single-line\n`status-bar` (`size=1`) at the bottom to keep the key hints — matching the default layout's look.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fotezz%2Fzellij-vtabs","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fotezz%2Fzellij-vtabs","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fotezz%2Fzellij-vtabs/lists"}