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
# 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
OSCis\033](ESC]).STis\033\\(ESC\). A BEL (\a) terminator also works — both are accepted, as with OSC 0/2 titles.<version>is currently1. 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, sostatus;a;b;cyields the texta;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.
# 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.
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
#!/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
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.
Button decoration
How buttons are drawn is a user choice, not a program one, since only the user knows whether the numbers mean anything on their setup:
ui { pane_frames { status_bar_button_style "indexed" } } // default: "boxed"
| style | renders |
|---|---|
boxed |
[Approve] [Reject] |
indexed |
1:Approve 2:Reject — 1-based position, then the label |
indexed pairs with a harness that binds those numbers to the same actions
(alt+1…alt+9, say), so the bar doubles as a key legend. The index is the
button's position in the list you declared, so keep the order stable if you are
binding keys to it.
Either way the click report is unchanged: it carries the id, never the index.
Rendering behaviour
- Requires pane frames. The bar lives in the frame's bottom row, so with
pane_frame_style "none"/"titles", or afterTogglePaneFrames, 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.
- The button under the pointer is highlighted (inverted), so it is obvious what a click will hit. Hover is tracked per client, so with two users attached one pointer does not light up the other's button.
- 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.
