aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorsl0p <sl0p@sl0p.foo>2026-08-13 23:07:27 +0200
committersl0p <sl0p@sl0p.foo>2026-08-13 23:07:27 +0200
commit105a6178542075ae212d396d10b7bd0fd5649f9c (patch)
tree8a8349cbda725b6fac78b18c9c6e16f566540aff /src
parentsl0ptty: M3 — tabs, purposes, and the JSON control API (diff)
downloadslosh-105a6178542075ae212d396d10b7bd0fd5649f9c.tar.gz
slosh-105a6178542075ae212d396d10b7bd0fd5649f9c.tar.xz
slosh-105a6178542075ae212d396d10b7bd0fd5649f9c.zip
sl0ptty: M4 — OSC 5577 pane status bars, buttons and click reports
The payoff milestone: sl0ppi's pi extensions work here unmodified. printf '\033]5577;1;status;building 3/7\033\\' printf '\033]5577;1;buttons;approve:Approve;cancel:Cancel\033\\' ╰ building 3/7 ──────────────────────[Approve]─[Cancel]─╯ libghostty-vt's UNKNOWN_SEQUENCE reports APC only, so the sequences are picked up by a scanner (osc5577.c) reading the same bytes the terminal does — lib-vt discards an OSC it does not know, so nothing is drawn and no pane output has to be buffered. The scanner is a byte-at-a-time state machine, so a sequence split across reads still lands. Buttons are budgeted from the right before the status text gets any columns, and each registers its hit as it is painted — the split button's rule, for the split button's reason. A press sends the click report to that pane's stdin. In-band purposes arrive here too, and by construction can never be declared: a pane printing one is asking, not telling. A pane cannot relabel a purpose a layout declared, which is now tested end to end rather than only at the API. One real bug, found by the hostile-input tests: an over-long button id was truncated into a *valid* one, so a pane could mint a 40-character id that collides with the 32-character id its program already trusts. Ids are now unescaped into a large buffer and validated at full length. Acceptance test replays answer-picker's exact byte patterns, including `buttons` with no payload (drop buttons, keep status) and a click report that matches the extension's own CLICK_RE. Four test bugs on the way, each worth remembering: printf eats %3B as a format specifier; the driver's unescaper reads \0 as NUL so \033 became NUL+'33'; a trailing ST inside a double-quoted shell string escapes the quote (BEL avoids it); and readline eats a typed ESC as a meta prefix, so tests that drive a pane must not use an interactive shell. 187 checks green (160 headless + 27 live).
Diffstat (limited to 'src')
-rw-r--r--src/app.c85
-rw-r--r--src/osc5577.c128
-rw-r--r--src/osc5577.h49
-rw-r--r--src/pane.c85
-rw-r--r--src/sl0ptty.h14
5 files changed, 359 insertions, 2 deletions
diff --git a/src/app.c b/src/app.c
index 13c8a6b..ffc8784 100644
--- a/src/app.c
+++ b/src/app.c
@@ -77,6 +77,30 @@ struct app {
static tab_t *cur(app_t *a) { return &a->tabs[a->cur]; }
+typedef void (*leaf_fn_fwd)(node_t *, void *);
+static void walk_all(app_t *a, leaf_fn_fwd fn, void *ud);
+
+struct bypane {
+ const pane_t *pane;
+ node_t *found;
+};
+static void bypane_cb(node_t *n, void *ud) {
+ struct bypane *b = ud;
+ if (n->pane == b->pane) b->found = n;
+}
+
+/* OSC 5577 verbs pane.c does not own. `purpose` arrives from a pane's own
+ * output, so it is in-band by definition and can never be declared: a pane
+ * that prints one is asking, not telling (D8). */
+static void on_pane_osc(pane_t *p, const char *verb, const char *payload,
+ void *ud) {
+ app_t *a = ud;
+ if (strcmp(verb, "purpose") != 0) return;
+ struct bypane b = {p, NULL};
+ walk_all(a, bypane_cb, &b);
+ if (b.found) app_set_pane_purpose(a, b.found->id, payload, false);
+}
+
static node_t *leaf_new(app_t *a) {
pane_t *p = pane_new(a->argv, 1, 1, NULL);
if (!p) return NULL;
@@ -84,6 +108,7 @@ static node_t *leaf_new(app_t *a) {
n->kind = NODE_LEAF;
n->pane = p;
n->id = ++a->next_id;
+ pane_set_osc_handler(p, on_pane_osc, a);
return n;
}
@@ -566,6 +591,53 @@ static void focus_next(app_t *a) {
/* ---- drawing ------------------------------------------------------------ */
+static const color_t BTN_FG = {true, 0x14, 0x14, 0x18};
+static const color_t BTN_BG = {true, 0xff, 0x5f, 0xd7};
+static const color_t BTN_BG_IDLE = {true, 0x55, 0x55, 0x5c};
+
+/* OSC 5577: a pane's own status line and buttons, drawn in its bottom frame
+ * row. Buttons are budgeted from the right *before* the status text gets any
+ * columns, and each registers its hit as it is painted — the same rule the
+ * split button follows, for the same reason. */
+static void draw_pane_status(screen_t *s, node_t *leaf, color_t fg,
+ bool focused) {
+ const pane_button_t *btns = NULL;
+ size_t nbtn = pane_buttons(leaf->pane, &btns);
+ const char *status = pane_status(leaf->pane);
+ if (!nbtn && !*status) return;
+
+ rect_t r = leaf->rect;
+ if (r.w < 6 || r.h < 3) return;
+ uint16_t y = (uint16_t)(r.y + r.h - 1);
+ uint16_t left = (uint16_t)(r.x + 1), right = (uint16_t)(r.x + r.w - 1);
+
+ /* right to left, so a button that does not fit is simply not drawn */
+ uint16_t x = right;
+ for (size_t i = nbtn; i-- > 0;) {
+ uint16_t w = (uint16_t)(strlen(btns[i].label) + 2); /* [label] */
+ if (x < left + w + 1) break;
+ x = (uint16_t)(x - w - 1);
+ char label[80];
+ snprintf(label, sizeof label, "[%s]", btns[i].label);
+ uint16_t drawn = screen_text(s, x, y, label, focused ? BTN_FG : fg,
+ focused ? BTN_BG : BTN_BG_IDLE, 0);
+ char action[48];
+ snprintf(action, sizeof action, "btn:%u:%s", leaf->id, btns[i].id);
+ hit_add(&s->hits, x, y, drawn, 1, action);
+ }
+
+ if (*status && x > left + 1) {
+ char buf[256];
+ int len = snprintf(buf, sizeof buf, " %s ", status);
+ uint16_t room = (uint16_t)(x - left);
+ if (len > (int)room) {
+ len = room;
+ buf[len] = 0;
+ }
+ screen_text(s, left, y, buf, focused ? TITLE_FOCUS : fg, NO_COLOR, 0);
+ }
+}
+
static void draw_frame(app_t *a, screen_t *s, node_t *leaf) {
rect_t r = leaf->rect;
if (r.w < 3 || r.h < 3) return;
@@ -606,6 +678,8 @@ static void draw_frame(app_t *a, screen_t *s, node_t *leaf) {
avail = (uint16_t)(avail - 3);
}
+ draw_pane_status(s, leaf, fg, focused);
+
const char *title = pane_title(leaf->pane);
if (title && *title && avail >= 3) {
char buf[256];
@@ -687,6 +761,17 @@ static node_t *by_id(app_t *a, uint32_t id) {
}
static void do_action(app_t *a, const char *action, const input_event_t *ev) {
+ if (strncmp(action, "btn:", 4) == 0) {
+ if (ev->maction != MOUSE_PRESS) return;
+ uint32_t id = (uint32_t)strtoul(action + 4, NULL, 10);
+ const char *bid = strchr(action + 4, ':');
+ node_t *n = pane_by_id(a, id);
+ if (n && bid) {
+ app_focus_pane(a, id);
+ pane_click_button(n->pane, bid + 1);
+ }
+ return;
+ }
if (strncmp(action, "tab:", 4) == 0) {
if (ev->maction == MOUSE_PRESS)
app_select_tab_id(a, (uint32_t)strtoul(action + 4, NULL, 10));
diff --git a/src/osc5577.c b/src/osc5577.c
new file mode 100644
index 0000000..35f23b4
--- /dev/null
+++ b/src/osc5577.c
@@ -0,0 +1,128 @@
+#define _GNU_SOURCE
+#include "osc5577.h"
+
+#include <stdlib.h>
+#include <string.h>
+
+void osc_scan_reset(osc_scan_t *s) {
+ s->state = OS_GROUND;
+ s->len = 0;
+ s->overflow = false;
+}
+
+/* Split "<version>;<verb>;<rest...>" and dispatch. The payload keeps every
+ * separator after the verb, so `status;a;b;c` is the text "a;b;c". */
+static void dispatch(const char *body, size_t len, osc5577_fn cb, void *ud) {
+ if (len < 5 || memcmp(body, "5577;", 5) != 0) return;
+ const char *p = body + 5;
+ const char *end = body + len;
+
+ const char *semi = memchr(p, ';', (size_t)(end - p));
+ if (!semi) return;
+ /* An unknown version is ignored *entirely*, so a future version cannot be
+ * half-interpreted by an older build. */
+ if (semi - p != 1 || *p != '1') return;
+ p = semi + 1;
+
+ semi = memchr(p, ';', (size_t)(end - p));
+ size_t verb_len = semi ? (size_t)(semi - p) : (size_t)(end - p);
+ char verb[32];
+ if (verb_len >= sizeof verb) return;
+ memcpy(verb, p, verb_len);
+ verb[verb_len] = 0;
+
+ const char *payload = semi ? semi + 1 : end;
+ size_t plen = (size_t)(end - payload);
+ char *buf = malloc(plen + 1);
+ memcpy(buf, payload, plen);
+ buf[plen] = 0;
+ cb(verb, buf, ud);
+ free(buf);
+}
+
+void osc_scan_feed(osc_scan_t *s, const uint8_t *data, size_t len,
+ osc5577_fn cb, void *ud) {
+ for (size_t i = 0; i < len; i++) {
+ uint8_t c = data[i];
+ switch (s->state) {
+ case OS_GROUND:
+ if (c == 0x1b) s->state = OS_ESC;
+ break;
+
+ case OS_ESC:
+ if (c == ']') {
+ s->state = OS_BODY;
+ s->len = 0;
+ s->overflow = false;
+ } else {
+ s->state = c == 0x1b ? OS_ESC : OS_GROUND;
+ }
+ break;
+
+ case OS_BODY:
+ if (c == 0x07) { /* BEL terminator, as accepted for OSC 0/2 */
+ if (!s->overflow) dispatch(s->buf, s->len, cb, ud);
+ s->state = OS_GROUND;
+ } else if (c == 0x1b) {
+ s->state = OS_BODY_ESC;
+ } else if (c < 0x20) {
+ s->state = OS_GROUND; /* a control byte ends a malformed OSC */
+ } else if (s->len < sizeof s->buf) {
+ s->buf[s->len++] = (char)c;
+ } else {
+ s->overflow = true; /* keep scanning to the terminator, then drop */
+ }
+ break;
+
+ case OS_BODY_ESC:
+ if (c == '\\') { /* ST */
+ if (!s->overflow) dispatch(s->buf, s->len, cb, ud);
+ s->state = OS_GROUND;
+ } else if (c == ']') {
+ s->state = OS_BODY; /* a new OSC started inside one: restart */
+ s->len = 0;
+ s->overflow = false;
+ } else {
+ s->state = OS_GROUND;
+ }
+ break;
+ }
+ }
+}
+
+static int hexval(char c) {
+ if (c >= '0' && c <= '9') return c - '0';
+ if (c >= 'a' && c <= 'f') return c - 'a' + 10;
+ if (c >= 'A' && c <= 'F') return c - 'A' + 10;
+ return -1;
+}
+
+size_t osc5577_unescape(const char *in, size_t in_len, char *out, size_t cap) {
+ size_t n = 0;
+ for (size_t i = 0; i < in_len && n + 1 < cap; i++) {
+ if (in[i] == '%' && i + 2 < in_len) {
+ int hi = hexval(in[i + 1]), lo = hexval(in[i + 2]);
+ if (hi >= 0 && lo >= 0) {
+ out[n++] = (char)(hi * 16 + lo);
+ i += 2;
+ continue;
+ }
+ /* An invalid escape is left literal rather than dropping text. */
+ }
+ out[n++] = in[i];
+ }
+ out[n] = 0;
+ return n;
+}
+
+bool osc5577_valid_id(const char *id) {
+ size_t n = strlen(id);
+ if (n == 0 || n > 32) return false;
+ for (size_t i = 0; i < n; i++) {
+ char c = id[i];
+ bool ok = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
+ (c >= '0' && c <= '9') || c == '_' || c == '-';
+ if (!ok) return false;
+ }
+ return true;
+}
diff --git a/src/osc5577.h b/src/osc5577.h
new file mode 100644
index 0000000..882fc06
--- /dev/null
+++ b/src/osc5577.h
@@ -0,0 +1,49 @@
+/* OSC 5577: the pane status bar and action buttons.
+ *
+ * Byte-compatible with the sl0ppi fork (D1), so the pi extensions written
+ * against zellij work here unmodified:
+ *
+ * printf '\033]5577;1;status;building 3/7\033\\'
+ * printf '\033]5577;1;buttons;approve:Approve;cancel:Cancel\033\\'
+ * printf '\033]5577;1;clear\033\\'
+ *
+ * and a click comes back on the pane's stdin as
+ *
+ * \033]5577;1;click;approve\033\\
+ *
+ * libghostty-vt's UNKNOWN_SEQUENCE effect reports APC only, not unknown OSC,
+ * so we scan the pty stream ourselves. The bytes still go to lib-vt, which
+ * discards an OSC it does not know — nothing is drawn, and we need no
+ * buffering that could stall a pane's output.
+ */
+#ifndef SL0PTTY_OSC5577_H
+#define SL0PTTY_OSC5577_H
+
+#include <stdbool.h>
+#include <stddef.h>
+#include <stdint.h>
+
+#define OSC5577_MAX 4096
+
+typedef struct {
+ enum { OS_GROUND, OS_ESC, OS_BODY, OS_BODY_ESC } state;
+ char buf[OSC5577_MAX];
+ size_t len;
+ bool overflow;
+} osc_scan_t;
+
+/* verb and payload are NUL-terminated and valid for the callback only. */
+typedef void (*osc5577_fn)(const char *verb, const char *payload, void *ud);
+
+void osc_scan_reset(osc_scan_t *s);
+void osc_scan_feed(osc_scan_t *s, const uint8_t *data, size_t len,
+ osc5577_fn cb, void *ud);
+
+/* %3B %3A %25 and friends. Writes at most cap-1 bytes plus a NUL. */
+size_t osc5577_unescape(const char *in, size_t in_len, char *out, size_t cap);
+/* A button id is [A-Za-z0-9_-]{1,32}. Deliberately narrow: ids are echoed
+ * back to the program, and a program that trusts its own ids should not have
+ * to defend against what a hostile label could smuggle through. */
+bool osc5577_valid_id(const char *id);
+
+#endif /* SL0PTTY_OSC5577_H */
diff --git a/src/pane.c b/src/pane.c
index 4890b52..1129665 100644
--- a/src/pane.c
+++ b/src/pane.c
@@ -4,12 +4,14 @@
#include "sl0ptty.h"
#include <errno.h>
+#include <stdio.h>
#include <ghostty/vt.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include "input.h"
+#include "osc5577.h"
struct pane {
pty_t pty;
@@ -25,8 +27,86 @@ struct pane {
bool alive;
bool dirty;
char title[256];
+
+ /* OSC 5577 state: what this pane asked us to draw in its frame */
+ osc_scan_t scan;
+ char status[256];
+ pane_button_t buttons[8];
+ size_t nbuttons;
+ pane_osc_fn osc_cb;
+ void *osc_ud;
};
+void pane_set_osc_handler(pane_t *p, pane_osc_fn fn, void *ud) {
+ p->osc_cb = fn;
+ p->osc_ud = ud;
+}
+
+const char *pane_status(const pane_t *p) { return p->status; }
+size_t pane_buttons(const pane_t *p, const pane_button_t **out) {
+ *out = p->buttons;
+ return p->nbuttons;
+}
+
+void pane_click_button(pane_t *p, const char *id) {
+ if (!osc5577_valid_id(id)) return;
+ char msg[64];
+ int n = snprintf(msg, sizeof msg, "\033]5577;1;click;%s\033\\", id);
+ pane_write(p, msg, (size_t)n);
+}
+
+/* Buttons are `id:label` fields, each %-escaped because the payload itself is
+ * ;-separated. An invalid id or an empty label drops that button and only
+ * that button. */
+static void parse_buttons(pane_t *p, const char *payload) {
+ p->nbuttons = 0;
+ const char *cur = payload;
+ while (*cur && p->nbuttons < sizeof p->buttons / sizeof *p->buttons) {
+ const char *semi = strchr(cur, ';');
+ size_t flen = semi ? (size_t)(semi - cur) : strlen(cur);
+ const char *colon = memchr(cur, ':', flen);
+ if (colon) {
+ pane_button_t b = {0};
+ /* Unescape the id into a buffer far larger than the limit, so an
+ * over-long id is *rejected* rather than truncated into a valid one.
+ * Truncating here would let `aaa...a<33 chars>` collide with a
+ * legitimate 32-char id that the program already trusts. */
+ char id[256];
+ osc5577_unescape(cur, (size_t)(colon - cur), id, sizeof id);
+ osc5577_unescape(colon + 1, flen - (size_t)(colon - cur) - 1, b.label,
+ sizeof b.label);
+ if (osc5577_valid_id(id) && b.label[0]) {
+ snprintf(b.id, sizeof b.id, "%s", id);
+ p->buttons[p->nbuttons++] = b;
+ }
+ }
+ if (!semi) break;
+ cur = semi + 1;
+ }
+ p->dirty = true;
+}
+
+static void on_osc5577(const char *verb, const char *payload, void *ud) {
+ pane_t *p = ud;
+ if (strcmp(verb, "status") == 0) {
+ snprintf(p->status, sizeof p->status, "%s", payload);
+ p->dirty = true;
+ } else if (strcmp(verb, "buttons") == 0) {
+ parse_buttons(p, payload);
+ } else if (strcmp(verb, "clear") == 0) {
+ p->status[0] = 0;
+ p->nbuttons = 0;
+ p->dirty = true;
+ } else if (strcmp(verb, "hello") == 0) {
+ /* Our addition to the fork's protocol, which is unversioned in practice:
+ * a program can ask what it is talking to before using anything new. */
+ const char *reply = "\033]5577;1;hello;sl0ptty;1\033\\";
+ pane_write(p, reply, strlen(reply));
+ } else if (p->osc_cb) {
+ p->osc_cb(p, verb, payload, p->osc_ud); /* purpose, and anything later */
+ }
+}
+
/* Terminal replies (DA, cursor position, XTVERSION...) go back to the app. */
static void on_write_pty(GhosttyTerminal t, void *ud, const uint8_t *data,
size_t len) {
@@ -133,8 +213,9 @@ ssize_t pane_pump(pane_t *p) {
for (;;) {
ssize_t n = read(p->pty.fd, buf, sizeof buf);
if (n > 0) {
- /* M4: a side-channel scanner for OSC 5577 hooks in here, before the
- * bytes reach the terminal. */
+ /* The scanner sees the same bytes the terminal does. lib-vt discards an
+ * OSC it does not know, so nothing is drawn and nothing is buffered. */
+ osc_scan_feed(&p->scan, buf, (size_t)n, on_osc5577, p);
ghostty_terminal_vt_write(p->term, buf, (size_t)n);
p->dirty = true;
total += n;
diff --git a/src/sl0ptty.h b/src/sl0ptty.h
index 2a87f75..828de0e 100644
--- a/src/sl0ptty.h
+++ b/src/sl0ptty.h
@@ -111,6 +111,16 @@ void pty_close(pty_t *p);
typedef struct pane pane_t;
+/* An OSC 5577 action button drawn in a pane's frame. */
+typedef struct {
+ char id[33];
+ char label[33];
+} pane_button_t;
+
+/* Verbs pane.c does not handle itself (purpose, and whatever comes later). */
+typedef void (*pane_osc_fn)(pane_t *p, const char *verb, const char *payload,
+ void *ud);
+
pane_t *pane_new(const char *const argv[], uint16_t cols, uint16_t rows,
const char *cwd);
void pane_free(pane_t *p);
@@ -127,6 +137,10 @@ void pane_send_paste(pane_t *p, const char *text, size_t len);
void pane_resize(pane_t *p, uint16_t cols, uint16_t rows);
bool pane_dirty(pane_t *p);
const char *pane_title(const pane_t *p);
+const char *pane_status(const pane_t *p);
+size_t pane_buttons(const pane_t *p, const pane_button_t **out);
+void pane_click_button(pane_t *p, const char *id);
+void pane_set_osc_handler(pane_t *p, pane_osc_fn fn, void *ud);
/* Compose this pane's viewport into the screen at (x0,y0). Clears dirty. */
void pane_compose(pane_t *p, screen_t *s, uint16_t x0, uint16_t y0,
bool focused);