aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-12 15:30:11 +0200
committeruser <user@clank>2026-08-12 15:30:11 +0200
commitf9376a5ab8708c95d6ba40f069c9c29dedf67421 (patch)
tree25124d44c0f54b0d5e4360fdd01ecfc8f1b70e30
parentsl0p: clickable action buttons in the pane status bar (diff)
downloadsl0ppij-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.md3
-rw-r--r--sl0p/pane-status-bar.md256
2 files changed, 259 insertions, 0 deletions
diff --git a/README.md b/README.md
index dbc22e54..fd99fd87 100644
--- a/README.md
+++ b/README.md
@@ -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.