diff options
| author | user <user@clank> | 2026-08-12 15:30:11 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-12 15:30:11 +0200 |
| commit | f9376a5ab8708c95d6ba40f069c9c29dedf67421 (patch) | |
| tree | 25124d44c0f54b0d5e4360fdd01ecfc8f1b70e30 | |
| parent | sl0p: clickable action buttons in the pane status bar (diff) | |
| download | sl0ppij-f9376a5ab8708c95d6ba40f069c9c29dedf67421.tar.gz sl0ppij-f9376a5ab8708c95d6ba40f069c9c29dedf67421.tar.xz sl0ppij-f9376a5ab8708c95d6ba40f069c9c29dedf67421.zip | |
sl0p: document the pane status bar protocol
Reference for programs wanting to drive the bar: the OSC 5577 verbs, escaping
rules, click reports, limits, rendering behaviour, persistence, and the security
model.
Both worked examples were run against a real session rather than written from
memory, which caught four bugs in my own samples:
- the Python reader kept a trailing buffer window, so a processed report was
re-matched and every click fired twice
- it used tty.setraw, which also disables signals, leaving the sample unkillable
with Ctrl-C
- it exited via traceback and left the bar behind; both samples now clear it on
exit
- the shell reader echoed the report to the screen, and an `stty -echo` did not
help because bash's `read -n` saves and restores terminal settings around each
call. `read -s` is the fix.
Also verified and documented two behaviours I would otherwise have guessed at: a
BEL terminator works as well as ST, and the bar is not drawn when pane frames are
off (it lives in the frame row), reappearing when they return.
| -rw-r--r-- | README.md | 3 | ||||
| -rw-r--r-- | sl0p/pane-status-bar.md | 256 |
2 files changed, 259 insertions, 0 deletions
@@ -331,6 +331,9 @@ printf '\033]5577;1;clear\033\\' # remove bo ╰ awaiting review ──────────────────────────[Approve]─[Reject]╯ ``` +Full protocol reference, worked shell/Python consumers and the security model: +**[`sl0p/pane-status-bar.md`](sl0p/pane-status-bar.md)**. + Clicking a button writes a **fixed-format report** to the program's stdin: ``` diff --git a/sl0p/pane-status-bar.md b/sl0p/pane-status-bar.md new file mode 100644 index 00000000..44801215 --- /dev/null +++ b/sl0p/pane-status-bar.md @@ -0,0 +1,256 @@ +# Pane status bar and action buttons (OSC 5577) + +**Status: experimental.** The protocol is versioned, but this is a fork-local +extension — no other terminal implements it, and the shape may still change. + +A program running in a pane can put one row of its own text, plus clickable +buttons, into that pane's bottom frame row. Clicks are reported back to the +program on its stdin. + +``` +╭──────────────────────── build.sh ────────────────────────╮ +│ ...program output... │ +│ │ +╰ building 3/7 · 12s ─────────────────[Approve]─[Cancel]───╯ + ^ your text ^ your buttons +``` + +Nothing appears until a program asks for it, so the feature costs nothing when +unused. + +--- + +## Quick start + +```bash +# set the text +printf '\033]5577;1;status;building 3/7\033\\' + +# declare two buttons +printf '\033]5577;1;buttons;approve:Approve;cancel:Cancel\033\\' + +# remove both +printf '\033]5577;1;clear\033\\' +``` + +When the user clicks a button, the program receives on **stdin**: + +``` +\033]5577;1;click;approve\033\\ +``` + +--- + +## Protocol + +All messages are OSC sequences with number `5577`: + +``` +OSC 5577 ; <version> ; <verb> ; <payload…> ST +``` + +- `OSC` is `\033]` (ESC `]`). +- `ST` is `\033\\` (ESC `\`). A BEL (`\a`) terminator also works — both are + accepted, as with OSC 0/2 titles. +- `<version>` is currently `1`. **Anything else is ignored entirely**, so you can + adopt a future version without older builds half-interpreting it. +- An unknown `<verb>` is likewise ignored. + +### Verbs your program sends + +| verb | payload | meaning | +|---|---|---| +| `status` | `<text>` | set the status text | +| `buttons` | `<id>:<label>` `;` … | replace the button list | +| `clear` | — | remove text **and** buttons | + +Each verb replaces the previous value of that kind. `status` does not affect +buttons, and vice versa; only `clear` removes both. Sending `status` with empty +text is equivalent to clearing just the text. + +### The verb zellij sends you + +| verb | payload | meaning | +|---|---|---| +| `click` | `<id>` | the user clicked the button with that id | + +--- + +## Escaping + +The payload is `;`-separated, so: + +- **Status text needs no escaping.** Everything after `status;` is rejoined, so + `status;a;b;c` yields the text `a;b;c`. +- **Button fields are `%`-escaped**, because each button is its own `;`-separated + field. Encode these in a label: + + | character | encode as | + |---|---| + | `;` | `%3B` | + | `:` | `%3A` | + | `%` | `%25` | + + Any other `%XX` hex escape is also decoded. An invalid escape is left literal + rather than dropping text. + +```bash +# a label containing a semicolon +printf '\033]5577;1;buttons;go:one%%3Btwo\033\\' # renders as [one;two] +``` + +## Button ids + +Ids are **restricted to `[A-Za-z0-9_-]`, max 32 characters**. A button with an +invalid or empty id is silently dropped, as is one with an empty label. + +This is deliberate and load-bearing — see [Security](#security). + +--- + +## Reading click reports + +The report arrives on stdin, interleaved with ordinary input. Treat it like any +other terminal reply sequence (cursor position, DA, OSC 11 …): scan the input +stream for it rather than assuming it arrives alone. + +### Shell + +```bash +#!/usr/bin/env bash +# clear the bar on the way out, however we exit +trap 'printf "\033]5577;1;clear\033\\"' EXIT + +printf '\033]5577;1;status;awaiting review\033\\' +printf '\033]5577;1;buttons;approve:Approve;reject:Reject\033\\' + +buf='' +# -s is what keeps the report off the screen: bash's `read -n` saves and restores +# terminal settings around each call, so an `stty -echo` beforehand gets undone. +while IFS= read -rsn1 c; do + buf+="$c" + case "$buf" in + *'5577;1;click;approve'*) echo "approved"; buf='' ;; + *'5577;1;click;reject'*) echo "rejected"; buf='' ;; + esac + # keep the buffer bounded; clearing it on a match also prevents + # re-reporting the same click + [ "${#buf}" -gt 64 ] && buf="${buf: -32}" +done +``` + +### Python + +```python +import re, sys, os, tty, termios + +CLICK = re.compile(r"\x1b\]5577;1;click;([A-Za-z0-9_-]+)(?:\x1b\\|\x07)") + +def set_bar(text, buttons): + sys.stdout.write(f"\x1b]5577;1;status;{text}\x1b\\") + spec = ";".join(f"{i}:{label}" for i, label in buttons) + sys.stdout.write(f"\x1b]5577;1;buttons;{spec}\x1b\\") + sys.stdout.flush() + +set_bar("awaiting review", [("approve", "Approve"), ("reject", "Reject")]) + +fd = sys.stdin.fileno() +old = termios.tcgetattr(fd) +try: + # cbreak, not raw: raw also disables signals, which would leave this + # unkillable with Ctrl-C. cbreak still delivers bytes unbuffered. + tty.setcbreak(fd) + buf = "" + while True: + buf += os.read(fd, 64).decode("utf-8", "replace") + consumed = 0 + for m in CLICK.finditer(buf): + print(f"clicked: {m.group(1)}\r") + consumed = m.end() + # Drop what we processed. Keeping a trailing window instead would + # re-match the same report on the next read and double-fire the click. + buf = buf[consumed:][-64:] +except KeyboardInterrupt: + pass +finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old) + sys.stdout.write("\x1b]5577;1;clear\x1b\\") # tidy up on the way out + sys.stdout.flush() +``` + +Note the regex accepts **either** terminator, since a future zellij could emit +BEL. Match on the id charset rather than `.*` so a partially received report is +not matched early. + +--- + +## Rendering behaviour + +- **Requires pane frames.** The bar lives in the frame's bottom row, so with + `pane_frame_style "none"`/`"titles"`, or after `TogglePaneFrames`, it is not + drawn. Nothing is lost — it reappears when frames come back. +- **Buttons are right-aligned and win the space.** They are actionable; the text + is informational, so the text truncates with `…` rather than pushing a button + off the row. +- **Buttons that cannot fit are dropped**, from the right. A dropped button is + not clickable — the renderer and the hit-test share one layout function, so + what is drawn is exactly what responds. +- **It outranks the frame's other hints** (the resize help text, mouse + shortcuts): those are discoverable elsewhere, this was explicitly requested. +- One row only, and it does **not** shrink the pane's content area — so setting + or clearing it never resizes your PTY and never sends you `SIGWINCH`. + +## Limits + +| thing | limit | what happens past it | +|---|---|---| +| status text | 512 characters | truncated | +| buttons | 8 | extra buttons dropped | +| id length | 32 | button dropped | +| control characters | not allowed | stripped from text and labels | + +Text and labels are sanitised: all control characters are removed. A raw `ESC` +would otherwise let the payload repaint the screen, and a newline would corrupt +the row. + +## Persistence + +- Survives re-renders, pane resizes, and **detach/reattach** — it lives on the + pane, not on the client. +- Survives **session resurrection**: text and buttons are written into the + serialised session layout and restored when the pane is recreated, so a + resurrected pane comes back showing what it showed before. + +That last point has a consequence worth knowing: a restored bar may describe work +that is no longer running. If that matters, clear or re-assert the bar on startup +rather than trusting what is on screen. + +## No keyboard access (yet) + +Buttons are click-only. There is deliberately no key binding to activate one — +that would need to interact with zellij's modes and is a separate design problem. + +--- + +## Security + +**Treat the bar as untrusted output, and the report as trusted-but-minimal.** + +These are in-band escape sequences, so anything that writes to the pane can emit +them — including `cat` of a file someone else wrote. Consequences: + +- A hostile file can draw a **misleading** status bar and misleading button + labels in your pane. There is no way for zellij to tell that apart from your + program's own output. Do not treat the bar as evidence of anything. +- A click can only ever produce `OSC 5577;1;click;<id>` with an id matching + `[A-Za-z0-9_-]{1,32}`. It is a fixed format and **never carries bytes the + sequence chose**. This is why the id charset is restricted: without it, a + hostile file could declare a button whose id was shell syntax, and a click + would type that into the stdin of whatever is running — a shell included. +- Your program should still **validate the id** against the buttons it actually + declared, and ignore anything else. A click report is a hint that a user + pressed something, not proof that your program put it there. + +If you are building something that takes consequential action on a click +(deploying, approving, deleting), require the same confirmation you would for a +keystroke you did not fully trust. |
