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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,8 @@ test-zellij-clipboard:
@python3 "$(DOTFILES)/scripts/test-zellij-clipboard.py"

test-zellij-status:
@python3 "$(DOTFILES)/scripts/test-zellij-status-config.py"
@cargo test --locked --manifest-path "$(DOTFILES)/zellij/status/Cargo.toml" attention_policy
@"$(UV)" run --script "$(DOTFILES)/scripts/test-zellij-status.py"
.PHONY: test-zellij-auto-title
test-zellij-auto-title:
Expand Down
2 changes: 1 addition & 1 deletion bin/zellij-balance
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ snapshot() {
if $tab != null and $current != $tab then error("origin moved tabs") else . end |
[.[]|select(.tab_id == $current)] as $all |
[$all[]|select(.is_plugin == false)] as $term |
if any($term[]; .is_floating or .is_fullscreen or .is_suppressed or .is_collapsed or .exited or (.index_in_pane_group|length)>0)
if any($term[]; .is_floating or .is_fullscreen or .is_suppressed or .is_collapsed or .pane_rows <= 1 or .exited or (.index_in_pane_group|length)>0)
then error("floating, zoomed, stacked, hidden or exited panes are unsupported") else . end |
# Bars outside the terminal rectangle are harmless. Embedded plugin panes are not.
($term|map(.pane_x)|min) as $x | ($term|map(.pane_y)|min) as $y |
Expand Down
146 changes: 140 additions & 6 deletions bin/zellij-status
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
#!/usr/bin/env python3
"""Supply host metadata for the native Zellij attention bar."""

import argparse
import json
import os
from pathlib import Path
import re
import runpy
import socket
import subprocess
import stat
import sys
import time
import tomllib
import uuid


def battery():
Expand All @@ -28,10 +33,139 @@ def battery():
return ""


DEFAULT_PRIORITY = [["marked", "needs_you", "failed"], ["working", "new_output", "bell"]]
ATTENTION_CLASSES = {"marked", "needs_you", "failed", "working", "new_output", "bell", "done", "quiet"}
MAX_CONFIG_BYTES = 16 * 1024


def attention_config():
base = Path(os.environ.get("XDG_CONFIG_HOME", ""))
if not base.is_absolute():
base = Path.home() / ".config"
path = base / "zellij/lane-status.toml"
try:
# Nonblocking open also prevents an accidental FIFO from hanging reload.
try:
fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK)
except FileNotFoundError:
return {"priority": DEFAULT_PRIORITY}
with os.fdopen(fd, "rb") as source:
if not stat.S_ISREG(os.fstat(source.fileno()).st_mode):
raise ValueError("expected a regular file")
raw = source.read(MAX_CONFIG_BYTES + 1)
if len(raw) > MAX_CONFIG_BYTES:
raise ValueError("configuration exceeds 16 KiB")
data = tomllib.loads(raw.decode("utf-8"))
if set(data) != {"attention"} or not isinstance(data["attention"], dict):
raise ValueError("expected only an [attention] table")
attention = data["attention"]
if set(attention) != {"priority"}:
raise ValueError("[attention] must contain only priority")
groups = attention["priority"]
if not isinstance(groups, list) or not groups:
raise ValueError("priority must be a nonempty list of nonempty lists")
seen = set()
for group in groups:
if not isinstance(group, list) or not group:
raise ValueError("priority must be a nonempty list of nonempty lists")
for name in group:
if not isinstance(name, str) or name not in ATTENTION_CLASSES:
raise ValueError(f"unknown attention class: {name!r}")
if name in seen:
raise ValueError(f"duplicate attention class: {name}")
seen.add(name)
return {"priority": groups}
except (OSError, ValueError, UnicodeError) as error:
raise RuntimeError(f"{path}: {error}") from error


def reload_attention(session, config):
if not session:
raise RuntimeError("Run inside Zellij or specify --session")
deadline = time.monotonic() + 5

def cli(*args):
remaining = deadline - time.monotonic()
if remaining <= 0:
raise RuntimeError("reload deadline exceeded")
result = subprocess.run(["zellij", "--session=" + session, *args],
stdin=subprocess.DEVNULL, capture_output=True,
text=True, timeout=remaining)
if result.returncode:
raise RuntimeError(result.stderr.strip() or "Zellij command failed")
return result.stdout

def snapshot(kind):
options = ["--all"] if kind == "panes" else []
rows = json.loads(cli("action", "list-" + kind, *options, "--json"))
if not isinstance(rows, list):
raise RuntimeError(f"Zellij did not return a {kind} list")
return rows

def attached_clients():
return {str(client) for tab in snapshot("tabs") for client in tab["other_focused_clients"]}

# Actions query an existing session only. Never attach or start a session.
panes = snapshot("panes")
# Native pane metadata expands ~, but does not resolve symlinks.
receiver_url = "file:" + str(Path.home() / ".config/zellij/plugins/lane-status.wasm")
receivers = [pane for pane in panes if pane.get("is_plugin")
and pane.get("is_suppressed") and pane.get("plugin_url") == receiver_url]
if len(receivers) != 1:
raise RuntimeError("Expected one existing suppressed lane-status receiver; no reload sent")
plugin_id = receivers[0]["id"]
before = attached_clients()
if not before:
raise RuntimeError("No attached clients; no reload sent")
request = uuid.uuid4().hex
payload = {"request": request, "plugin_id": plugin_id, **config}
try:
# URL-directed pipes can launch a replacement if the receiver exits
# after discovery. Broadcast only visits existing instances; the
# receiver validates plugin_id before applying, in each client clone.
output = cli("pipe", "--name", "lane-attention-reload", "--", json.dumps(payload))
replies = [json.loads(line) for line in output.splitlines() if line.strip()]
confirmed = set()
for reply in replies:
if reply.get("request") != request or reply.get("plugin_id") != plugin_id:
raise RuntimeError("Unexpected reload acknowledgement identity")
if reply.get("error"):
raise RuntimeError(str(reply["error"]))
if reply.get("ok") is not True or "client_id" not in reply:
raise RuntimeError("Invalid reload acknowledgement")
confirmed.add(str(reply["client_id"]))
expected = before | attached_clients()
if not confirmed or not expected <= confirmed:
missing = ", ".join(sorted(expected - confirmed))
raise RuntimeError(f"Missing reload acknowledgements for clients: {missing}")
except (OSError, subprocess.SubprocessError, ValueError, KeyError, TypeError, AttributeError, RuntimeError) as error:
raise RuntimeError(f"Reload outcome may be partial: {error}") from error
print(f"Reloaded attention priority for {len(expected)} attached client(s) in {session}")


def main():
parser = argparse.ArgumentParser(description=__doc__)
commands = parser.add_subparsers(dest="command", required=True)
view = commands.add_parser("view", help="print host metadata")
view.add_argument("session", nargs="?")
commands.add_parser("config", help="validate and print attention configuration")
reload = commands.add_parser("reload", help="reload attention priority in one existing session")
reload.add_argument("--session", default=os.environ.get("ZELLIJ_SESSION_NAME"))
args = parser.parse_args()
if args.command == "config":
print(json.dumps(attention_config()))
elif args.command == "reload":
reload_attention(args.session, attention_config())
else:
ownership = runpy.run_path(str(Path(__file__).resolve().with_name("zellij-title-state")))
titles = ownership["owned_titles"](args.session) if args.session is not None else {}
print(json.dumps({"session": args.session, "auto_titles": titles, "host": socket.gethostname().split(".")[0],
"clock": time.strftime("%H:%M"), "battery": battery()}))


if __name__ == "__main__":
if len(sys.argv) not in (2, 3) or sys.argv[1] != "view":
sys.exit("Usage: zellij-status view [session]")
ownership = runpy.run_path(str(Path(__file__).resolve().with_name("zellij-title-state")))
titles = ownership["owned_titles"](sys.argv[2]) if len(sys.argv) == 3 else {}
print(json.dumps({"session": sys.argv[2] if len(sys.argv) == 3 else None, "auto_titles": titles, "host": socket.gethostname().split(".")[0],
"clock": time.strftime("%H:%M"), "battery": battery()}))
try:
main()
except (OSError, subprocess.SubprocessError, ValueError, KeyError, TypeError, AttributeError, RuntimeError) as error:
print(f"zellij-status: {error}", file=sys.stderr)
sys.exit(1)
11 changes: 6 additions & 5 deletions docs/zellij-keybinds.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Most commands return immediately to your application. **Esc**, **Enter**, or **C
| [Open or hide the shared popup](zellij/shared-popup.mdx) | **Ctrl-f P** |
| [Copy text from scrollback](zellij/copy-paste.mdx) | **Ctrl-f e** |
| Paste the session's last Neovim yank | **Ctrl-f p** |
| [Visit a tab needing attention](zellij/attention.mdx) | **Ctrl-f Ctrl-a** |
| [Jump to the highest-ranked activity tab](zellij/attention.mdx) | **Ctrl-f a** |
| [Open notification history](zellij/notifications.mdx) | **Ctrl-f B** |
| Detach, leaving programs running | **Ctrl-f d** |

Expand Down Expand Up @@ -107,10 +107,11 @@ The popup uses the same keys as a regular session. Inside it, **Ctrl-f P** hides
| `p` | Paste the session's last Neovim yank |
| `[` | Enter Scroll mode |
| `/` | Enter a scrollback search query |
| `Ctrl-a` | [Visit another local tab needing you, otherwise activity/bell](zellij/attention.mdx) |
| `a` | [Jump to the highest-ranked local activity tab](zellij/attention.mdx) |
| `Ctrl-a` | [Cycle through other local tabs needing you, otherwise activity/bell](zellij/attention.mdx) |
| `b` | Open or close the inbox/session card |
| `B` | [Search recorded notifications with source-pane previews](zellij/notifications.mdx) |
| `a` | Toggle watching the focused pane |
| `A` | Toggle watching the focused pane |
| `U` | Toggle muting the focused pane |
| `f` | Send a literal Ctrl-f to the application |
| `Esc` / `Enter` / `Ctrl-c` | Cancel the prefix |
Expand Down Expand Up @@ -197,8 +198,8 @@ If your installed keys differ, you may be using the older configuration. Modern-
| Tab positions | Start at 0 | Start at 1; `0` and `1` both choose the first tab |
| Session creation / rename: `C` / `w` | Direct actions | Open the session manager |
| Balance / golden ratio: `=` / `g` / `G` | Available | Unbound |
| Local attention: `Ctrl-a` | Available | Unbound |
| Inbox / history / watch / mute: `b` / `B` / `a` / `U` | Available | Unbound |
| Local attention: `a` / `Ctrl-a` | Direct jump / cycle | Unbound |
| Inbox / history / watch / mute: `b` / `B` / `A` / `U` | Available | Unbound |
| Session card: `I` | Available | Unbound |
| Client chooser: `D` | Available | Unbound |
| Session paste: `p` | Available | Unbound |
Expand Down
52 changes: 48 additions & 4 deletions docs/zellij/attention.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,59 @@ description: "Find work that needs an answer without checking every tab."
| `✓` | A task completed |
| `●` | A task is working |

## Visit the next tab that needs you
## Jump to the highest-ranked activity tab

Press **Ctrl-f Ctrl-a**. Zellij first looks for another tab with a request or failure. If none needs you, it visits a tab with activity or a bell. It wraps through tab order and skips quiet completions.
Press **Ctrl-f a** to jump directly, without opening a menu. Requests and failures rank ahead of activity or bells; ties use tab order. Quiet completions are skipped. If you are already on the highest-ranked tab, you stay there.

This shortcut stays in the current session and changes only your view. From the shared popup, press **Ctrl-f u** first if you want to triage the containing session's tabs.
**Ctrl-f Ctrl-a** instead cycles through other tabs at the highest eligible rank, wrapping through tab order.

Both shortcuts stay in the current session and change only your view. From the shared popup, press **Ctrl-f u** first if you want to triage the containing session's tabs.

## Choose attention priority

The defaults put native `!`-marked tabs, nonbell attention, and failures in the first group, then working programs, native new-output status, and bells in the second. Completed work and quiet tabs are excluded. Both jump shortcuts use the same priority groups.

To change them, create `zellij/lane-status.toml` under your absolute `XDG_CONFIG_HOME`, or `~/.config/zellij/lane-status.toml` when `XDG_CONFIG_HOME` is unset, empty, or relative. This example spells out the defaults:

```toml
[attention]
priority = [["marked", "needs_you", "failed"], ["working", "new_output", "bell"]]
```

Groups earlier in the list win. Classes in the same group have equal priority; ties use tab order. Omitted classes are excluded. The available classes are:

| Class | Includes |
| --- | --- |
| `marked` | Tabs whose name starts with the native `!` marker |
| `needs_you` | Nonbell requests, watch results, and notifications needing attention |
| `failed` | Failed tasks |
| `working` | Running tasks |
| `new_output` | Native new-output status; watch results instead use `needs_you` |
| `bell` | Terminal bells |
| `done` | Completed tasks; opt-in |
| `quiet` | Quiet tabs; opt-in |

For example, `priority = [["working"], ["needs_you", "failed"]]` visits working tabs before requests and failures, and excludes all other classes.

Each new client attachment, including the first client in a new session, loads the file after the status bar's existing permission grant. Invalid configuration at first load produces a warning and uses defaults. Already-attached clients keep their cached policy until reload. To apply edits to all attached clients in a running session, run this inside that session:

```sh
zellij-status reload
```

Or target it explicitly from another shell:

```sh
zellij-status reload --session NAME
```

Reload applies to every attached client in the selected session without moving anyone's focus. Already-attached clients in other sessions keep their current policy. Invalid configuration makes an explicit reload fail and leaves the previous policy unchanged. Removing the file and reloading restores the defaults. Reloading the same file again is safe. Edits are not polled, and jumping does not reread the file.

This only changes **Ctrl-f a** and **Ctrl-f Ctrl-a**. Direct jump includes the current tab; cycling excludes it. **Ctrl-f A** still toggles watching a pane.

## Watch or mute a pane

Focus a pane and press **Ctrl-f a** to toggle watching it for output. **Ctrl-f U** toggles muting it. Ordinary background output stays quiet unless you choose to watch it.
Focus a pane and press **Ctrl-f A** to toggle watching it for output. **Ctrl-f U** toggles muting it. Ordinary background output stays quiet unless you choose to watch it.

Claude and Codex can publish task status directly. Other programs may report only a bell or watched output. Successful shell commands become attention after running for at least thirty seconds; a quick successful command does not interrupt you.

Expand Down
16 changes: 14 additions & 2 deletions scripts/test-zellij-layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@ def main():
for count in (2, 3):
terminal.action("new-pane", "--direction", "right", "--cwd", str(home), "--", "/bin/sh")
terminal.until("explicit-command columns", lambda: len(terminal.terminals()) == count)
rightmost = max(terminal.terminals(), key=lambda pane: pane["pane_x"])["id"]
for _ in range(count - 1):
terminal.prefix(b"l")
terminal.until("new right-hand pane owns focus", lambda:
any(p["id"] == rightmost and p["is_focused"] for p in terminal.terminals()))
panes = terminal.terminals()
ids = {p["id"] for p in panes}
source_tab = panes[0]["tab_id"]
Expand Down Expand Up @@ -118,7 +123,7 @@ def content():
terminal.until("original PID", lambda: (home / f"pid-{pid}").exists())
pids[pid] = (home / f"pid-{pid}").read_text().strip()
assert len(set(pids.values())) == 3
assert widths() == [60, 30, 30]
assert widths() == [60, 30, 30], widths()
invoke(b"=")
assert widths() == [42, 36, 42], widths()
invoke(b"g")
Expand Down Expand Up @@ -195,6 +200,11 @@ def content():
terminal.until("unzoomed", lambda: not any(p["is_fullscreen"] for p in current()))
# Native layout cycling remains a separate, explicitly rearranging control.
terminal.action("apply-tiled-swap-layout", "stacked")
# The pinned runtime converts stacks into lists with suppressed
# inactive members; classic one-row geometry is only transitional.
terminal.until("stacked preset applied", lambda:
next(t for t in terminal.tabs() if t["tab_id"] == source_tab)["active_swap_layout_name"] == "stacked"
and sum(p["is_suppressed"] for p in current()) == len(ids) - 1)
settled = geometry(current())
invoke(b"=", failure="stacked")
assert geometry(current()) == settled
Expand All @@ -204,7 +214,9 @@ def content():
break
else:
raise AssertionError("Native layout cycle did not offer equalize")
terminal.until("preset restored", lambda: len({p["pane_y"] for p in current()}) == 2)
terminal.until("preset restored", lambda:
not any(p["is_suppressed"] for p in current())
and len({p["pane_y"] for p in current()}) == 2)
fcntl.ioctl(terminal.fd, termios.TIOCSWINSZ, struct.pack("HHHH", 24, 80, 0, 0))
terminal.until("native preset fits a smaller terminal", lambda:
sorted(geometry(current()).values()) ==
Expand Down
Loading
Loading