1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
|
# 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.
---
## 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:
```kdl
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.
|