aboutsummaryrefslogtreecommitdiffstats

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
  • 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.

# 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 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.
  • 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.