diff options
| author | sl0p <sl0p@sl0p.foo> | 2026-08-13 23:07:27 +0200 |
|---|---|---|
| committer | sl0p <sl0p@sl0p.foo> | 2026-08-13 23:07:27 +0200 |
| commit | 105a6178542075ae212d396d10b7bd0fd5649f9c (patch) | |
| tree | 8a8349cbda725b6fac78b18c9c6e16f566540aff /src | |
| parent | sl0ptty: M3 — tabs, purposes, and the JSON control API (diff) | |
| download | slosh-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.c | 85 | ||||
| -rw-r--r-- | src/osc5577.c | 128 | ||||
| -rw-r--r-- | src/osc5577.h | 49 | ||||
| -rw-r--r-- | src/pane.c | 85 | ||||
| -rw-r--r-- | src/sl0ptty.h | 14 |
5 files changed, 359 insertions, 2 deletions
@@ -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 */ @@ -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); |
