diff options
| author | user <user@clank> | 2026-10-09 12:24:48 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-10-09 12:31:53 +0200 |
| commit | fcf305085343bcb5c5240ed642468461f247f1ed (patch) | |
| tree | da00e12658bc22fee0e9f25e634572cf2aec9b53 /src | |
| parent | tab bar: a sidebar that fits the terminal, and gives up the edge last (diff) | |
| download | slosh-fcf305085343bcb5c5240ed642468461f247f1ed.tar.gz slosh-fcf305085343bcb5c5240ed642468461f247f1ed.tar.xz slosh-fcf305085343bcb5c5240ed642468461f247f1ed.zip | |
osc 7501: say what you are doing in somebody else's protocol
OSC 5577 only works for programs that know slosh exists. The program
status protocol is the same idea written down for everybody, by ghostty's
author and implemented by ghostty and Rex, so a program that already
speaks it needs to know nothing about us:
printf '\033]7501;state=working:app=cargo:msg=YnVpbGRpbmc=\033\\'
Implemented *as well as* 5577, not instead of it. 5577 is a two-way
control channel -- buttons answer on the program's stdin, shaders are
refused with a reason -- and 7501 deliberately is not, so neither absorbs
the other. What 7501 buys is the programs we do not control.
The scanner becomes one pass for four things: 5577, 7501, OSC 133 ; A and
RIS. The last two are here because 7501 ties record lifetime to them -- a
new prompt or a full reset is how a stale `working` dies without a
heartbeat -- and because a pane should pay for that scan once. osc5577_fn
gives way to osc_scan_cbs_t.
Reports are all-or-nothing, which is the spec's rule: no state, an unknown
state, a broken limit, base64 that will not decode, a control character in
decoded text or a malformed id discards the whole report rather than
writing a partial one. A malformed *pair* is skipped and the rest parsed.
A pane keeps up to PANE_PS_MAX records with LRU eviction, and composes the
one line it has from them by precedence -- blocked, error, done, working,
idle -- resolving `app` from the nearest ancestor that has one, the way
the hierarchy is allowed to mean something. It lands in the slot 5577's
`status` already had, because every reader of a pane wants "what is this
doing", not "which protocol said so"; `panes` reports program_state,
program_kind, program_app and progress beside it, with -1 for a progress
nobody reported, since a bar drawn at 0% says something false.
The feature-detection reply is rate-limited here. Note for the next
re-vendor: upstream lib-vt implements 7501 too, newer than our pin, and
answers the support query itself through write_pty without rate-limiting
it. See "What upstream did" under D1 in DESIGN.md before swapping this
half of the file for GHOSTTY_TERMINAL_OPT_PROGRAM_STATUS.
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.c | 15 | ||||
| -rw-r--r-- | src/osc5577.c | 351 | ||||
| -rw-r--r-- | src/pane.c | 276 |
3 files changed, 636 insertions, 6 deletions
@@ -2210,6 +2210,21 @@ static void panes_cb(node_t *n, void *ud) { const char *st = pane_status(n->pane); json_str(j, "status", st ? st : "", st ? strlen(st) : 0); json_bool(j, "busy", pane_status_busy(n->pane)); + /* ...and, when the program said it over OSC 7501 rather than 5577, the + * structure behind that line: which of the five states it is in, what it is + * waiting for, how far along, and what the program calls itself. `status` + * and `busy` stay the answer to "what is this pane doing" either way, so a + * script that only knows those keeps working against a program that has + * since switched protocols. */ + const osc7501_t *ps = pane_program_status(n->pane); + json_str(j, "program_state", ps ? osc7501_state_name(ps->state) : "", + ps ? strlen(osc7501_state_name(ps->state)) : 0); + json_str(j, "program_kind", ps ? osc7501_kind_name(ps->kind) : "", + ps ? strlen(osc7501_kind_name(ps->kind)) : 0); + json_str(j, "program_app", ps ? ps->app : "", ps ? strlen(ps->app) : 0); + /* -1 for "not reported", which is also what the protocol means by an absent + * progress: unknown, not zero. A bar drawn at 0% says something false. */ + json_int(j, "progress", ps ? ps->progress : -1); json_obj_close(j); } diff --git a/src/osc5577.c b/src/osc5577.c index dd2ec26..a30b18a 100644 --- a/src/osc5577.c +++ b/src/osc5577.c @@ -12,8 +12,8 @@ void osc_scan_reset(osc_scan_t *s) { /* 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; +static void dispatch_5577(const char *body, size_t len, osc5577_fn cb, + void *ud) { const char *p = body + 5; const char *end = body + len; @@ -40,8 +40,337 @@ static void dispatch(const char *body, size_t len, osc5577_fn cb, void *ud) { free(buf); } +/* ---------------------------------------------------------------- OSC 7501 */ + +const char *osc7501_state_name(ps_state_t s) { + switch (s) { + case PS_IDLE: return "idle"; + case PS_WORKING: return "working"; + case PS_DONE: return "done"; + case PS_BLOCKED: return "blocked"; + case PS_ERROR: return "error"; + default: return ""; + } +} + +const char *osc7501_kind_name(ps_kind_t k) { + switch (k) { + case PS_KIND_PERMISSION: return "permission"; + case PS_KIND_QUESTION: return "question"; + case PS_KIND_AUTH: return "auth"; + default: return ""; + } +} + +bool osc7501_is_descendant(const char *id, const char *parent) { + if (!*parent) return *id != 0; /* the root is above everything but itself */ + size_t n = strlen(parent); + return strncmp(id, parent, n) == 0 && id[n] == '/'; +} + +/* value := [A-Za-z0-9_.,+/=-]* -- no `:` and no `;`, which is why nothing in + * this protocol needs escaping, and base64's `+/=` all fit. */ +static bool value_byte(char c) { + return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || c == '_' || c == '.' || c == ',' || + c == '+' || c == '/' || c == '=' || c == '-'; +} + +static bool app_byte(char c) { + return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || c == '_' || c == '.' || c == '+' || c == '-'; +} + +/* An id segment allows the same bytes an app name does. Named separately + * because the spec states them as two grammars, and a later revision is free + * to widen one without the other. */ +static bool id_byte(char c) { return app_byte(c); } + +static bool valid_id(const char *id, size_t len) { + if (len == 0) return true; /* absent: the root record */ + if (len > 128) return false; + size_t seg = 0, depth = 1; + for (size_t i = 0; i < len; i++) { + if (id[i] == '/') { + if (seg == 0) return false; /* empty segment: "a//b", "/a", "a/" */ + seg = 0; + if (++depth > 8) return false; + continue; + } + if (!id_byte(id[i])) return false; + if (++seg > 32) return false; + } + return seg != 0; +} + +static int b64val(char c) { + if (c >= 'A' && c <= 'Z') return c - 'A'; + if (c >= 'a' && c <= 'z') return c - 'a' + 26; + if (c >= '0' && c <= '9') return c - '0' + 52; + if (c == '+') return 62; + if (c == '/') return 63; + return -1; +} + +/* Standard base64, padding optional (the spec allows either). Writes the + * decoded bytes to `out`, which must hold `cap`; returns the decoded length, + * or -1 for input that is not base64 or does not fit. */ +static long b64decode(const char *in, size_t len, uint8_t *out, size_t cap) { + uint32_t acc = 0; + int bits = 0; + size_t n = 0; + for (size_t i = 0; i < len; i++) { + if (in[i] == '=') { /* padding, and nothing but padding after it */ + for (size_t j = i; j < len; j++) + if (in[j] != '=') return -1; + break; + } + int v = b64val(in[i]); + if (v < 0) return -1; + acc = (acc << 6) | (uint32_t)v; + bits += 6; + if (bits >= 8) { + bits -= 8; + if (n >= cap) return -1; + out[n++] = (uint8_t)(acc >> bits); + } + } + /* Leftover bits must be zero padding, not a truncated character: a stray + * 6 bits means the sender's encoder, or the sender, is lying. */ + if (bits >= 6) return -1; + if (bits && (acc & ((1u << bits) - 1))) return -1; + return (long)n; +} + +/* Decoded text must be well-formed UTF-8 with no control characters: C0, DEL + * and C1 (kitty's "escape code safe UTF-8", which is what the spec means and + * what libghostty-vt checks with `encoding.isSafeUtf8`). + * + * Both halves matter and for different reasons. The control characters are the + * one thing this protocol is careful not to hand a program -- a way to draw + * outside its own pane -- and checking bytes rather than codepoints would miss + * a C1 written as `C2 9B`. Well-formedness matters because this text leaves + * the terminal: it is drawn in a frame, and it is handed to the control API as + * JSON, where a malformed sequence makes somebody else's parser the one + * dealing with our untrusted input. */ +static bool safe_utf8(const uint8_t *s, size_t len) { + for (size_t i = 0; i < len;) { + uint8_t c = s[i]; + uint32_t cp; + size_t n; + if (c < 0x80) { + cp = c; + n = 1; + } else if ((c & 0xe0) == 0xc0) { + cp = c & 0x1fu; + n = 2; + } else if ((c & 0xf0) == 0xe0) { + cp = c & 0x0fu; + n = 3; + } else if ((c & 0xf8) == 0xf0) { + cp = c & 0x07u; + n = 4; + } else { + return false; /* a continuation byte or an invalid lead */ + } + if (i + n > len) return false; + for (size_t k = 1; k < n; k++) { + if ((s[i + k] & 0xc0) != 0x80) return false; + cp = (cp << 6) | (s[i + k] & 0x3fu); + } + /* Overlongs, surrogates and anything past the last plane: all of them + * encode a codepoint some decoder somewhere will read differently. */ + static const uint32_t min[5] = {0, 0, 0x80, 0x800, 0x10000}; + if (cp < min[n] || cp > 0x10ffff) return false; + if (cp >= 0xd800 && cp <= 0xdfff) return false; + if (cp < 0x20 || cp == 0x7f || (cp >= 0x80 && cp <= 0x9f)) return false; + i += n; + } + return true; +} + +/* Copy up to cap-1 bytes without splitting a UTF-8 sequence: a truncated + * status is a shortened one, not a broken one. + * + * The boundary walk only runs when there is something to cut off. Doing it + * unconditionally reads src[len] -- one past the decoded bytes, which is + * uninitialised stack -- and a byte that happened to look like a continuation + * there cost every message its last character. */ +static void keep_text(char *dst, size_t cap, const uint8_t *src, size_t len) { + size_t n = len; + if (n > cap - 1) { + n = cap - 1; + while (n > 0 && (src[n] & 0xc0) == 0x80) n--; /* back to a lead byte */ + } + memcpy(dst, src, n); + dst[n] = 0; +} + +static void trim(const char **p, const char **end) { + while (*p < *end && (**p == ' ' || **p == '\t')) (*p)++; + while (*end > *p && ((*end)[-1] == ' ' || (*end)[-1] == '\t')) (*end)--; +} + +static bool keyis(const char *k, size_t klen, const char *name) { + return klen == strlen(name) && memcmp(k, name, klen) == 0; +} + +/* One key=value pair into the report under construction. False discards the + * whole report, which is the spec's answer to anything that breaks a limit -- + * so the check happens here, before a record has been touched. */ +static bool apply(osc7501_t *r, const char *k, size_t klen, const char *v, + size_t vlen) { + if (keyis(k, klen, "state")) { + if (keyis(v, vlen, "idle")) + r->state = PS_IDLE; + else if (keyis(v, vlen, "working")) + r->state = PS_WORKING; + else if (keyis(v, vlen, "done")) + r->state = PS_DONE; + else if (keyis(v, vlen, "blocked")) + r->state = PS_BLOCKED; + else if (keyis(v, vlen, "error")) + r->state = PS_ERROR; + else if (keyis(v, vlen, "clear")) + r->state = PS_CLEAR; + /* An unrecognised state discards the report rather than degrading to + * idle, so a state added in a later revision cannot make an older build + * announce the opposite of what is happening. */ + else + return false; + return true; + } + if (keyis(k, klen, "id")) { + if (!valid_id(v, vlen)) return false; /* never falls back to the root */ + memcpy(r->id, v, vlen); + r->id[vlen] = 0; + return true; + } + if (keyis(k, klen, "kind")) { + if (keyis(v, vlen, "permission")) + r->kind = PS_KIND_PERMISSION; + else if (keyis(v, vlen, "question")) + r->kind = PS_KIND_QUESTION; + else if (keyis(v, vlen, "auth")) + r->kind = PS_KIND_AUTH; + else + r->kind = PS_KIND_NONE; /* unrecognised: absent */ + return true; + } + if (keyis(k, klen, "progress")) { + r->progress = -1; /* anything but an integer 0..100 is "unknown" */ + if (!vlen || vlen > 3) return true; + int n = 0; + for (size_t i = 0; i < vlen; i++) { + if (v[i] < '0' || v[i] > '9') return true; + n = n * 10 + (v[i] - '0'); + } + if (n <= 100) r->progress = n; + return true; + } + if (keyis(k, klen, "app")) { + if (vlen > 32) return false; /* a limit, so: discard */ + for (size_t i = 0; i < vlen; i++) + if (!app_byte(v[i])) { + r->app[0] = 0; /* outside the character set: absent */ + return true; + } + memcpy(r->app, v, vlen); + r->app[vlen] = 0; + return true; + } + bool msg = keyis(k, klen, "msg"), title = keyis(k, klen, "title"); + if (msg || title) { + /* Encoded size first, so a hostile report cannot make us decode megabytes + * to find out it was too big. */ + if (vlen > (msg ? 2732u : 256u)) return false; + uint8_t dec[2049]; + long n = b64decode(v, vlen, dec, msg ? 2048 : 192); + if (n < 0) return false; + if (!safe_utf8(dec, (size_t)n)) return false; + keep_text(msg ? r->msg : r->title, OSC7501_KEEP, dec, (size_t)n); + return true; + } + return true; /* an unknown key is ignored: that is how this is extended */ +} + +bool osc7501_parse(const char *pairs, size_t len, osc7501_t *out) { + if (len > OSC7501_MAX_BODY) return false; + osc7501_t r = {.progress = -1}; + const char *p = pairs, *end = pairs + len; + bool any_state = false; + + while (p < end) { + const char *colon = memchr(p, ':', (size_t)(end - p)); + const char *fend = colon ? colon : end; + const char *fp = p; + p = colon ? colon + 1 : end; + + trim(&fp, &fend); + const char *eq = memchr(fp, '=', (size_t)(fend - fp)); + if (!eq) continue; /* malformed pair: skipped, the rest still counts */ + const char *ke = eq, *vs = eq + 1; + trim(&fp, &ke); + trim(&vs, &fend); + size_t klen = (size_t)(ke - fp), vlen = (size_t)(fend - vs); + + if (!klen) continue; + if (klen > 16) return false; /* a limit, not a malformed pair */ + bool ok = true; + for (size_t i = 0; i < klen; i++) + if (fp[i] < 'a' || fp[i] > 'z') ok = false; + for (size_t i = 0; i < vlen; i++) + if (!value_byte(vs[i])) ok = false; + if (!ok) continue; + + if (!apply(&r, fp, klen, vs, vlen)) return false; + if (keyis(fp, klen, "state")) any_state = true; + } + + if (!any_state) return false; /* state is the one required key */ + /* kind and progress exist only where the spec says they mean anything. */ + if (r.state != PS_BLOCKED) r.kind = PS_KIND_NONE; + if (r.state != PS_WORKING && r.state != PS_BLOCKED) r.progress = -1; + *out = r; + return true; +} + +static void dispatch_7501(const char *body, size_t len, bool bel, + const osc_scan_cbs_t *cbs, void *ud) { + const char *pairs = body + 5; + size_t plen = len - 5; + + /* Feature detection: `OSC 7501 ; ?`, answered with the same body -- and, + * because the program is matching on bytes it chose, the same terminator it + * asked with. (lib-vt does the same upstream.) */ + if (plen == 1 && *pairs == '?') { + if (cbs->status_query) cbs->status_query(bel, ud); + return; + } + osc7501_t rep; + if (!cbs->status || !osc7501_parse(pairs, plen, &rep)) return; + cbs->status(&rep, ud); +} + +static void dispatch(const char *body, size_t len, bool bel, + const osc_scan_cbs_t *cbs, void *ud) { + if (len >= 5 && memcmp(body, "5577;", 5) == 0) { + if (cbs->verb) dispatch_5577(body, len, cbs->verb, ud); + return; + } + if (len >= 5 && memcmp(body, "7501;", 5) == 0) { + dispatch_7501(body, len, bel, cbs, ud); + return; + } + /* OSC 133 ; A: a shell prompt begins, with or without the parameters some + * shells add (`133;A;aid=1`). Only this one subcommand, and only as a + * lifetime signal -- we are not implementing shell integration here. */ + if (len >= 5 && memcmp(body, "133;A", 5) == 0 && (len == 5 || body[5] == ';')) + if (cbs->prompt) cbs->prompt(ud); +} + void osc_scan_feed(osc_scan_t *s, const uint8_t *data, size_t len, - osc5577_fn cb, void *ud) { + const osc_scan_cbs_t *cbs, void *ud) { for (size_t i = 0; i < len; i++) { uint8_t c = data[i]; switch (s->state) { @@ -54,6 +383,12 @@ void osc_scan_feed(osc_scan_t *s, const uint8_t *data, size_t len, s->state = OS_BODY; s->len = 0; s->overflow = false; + } else if (c == 'c') { + /* RIS. 7501 says a full reset removes every record (a *soft* reset, + * CSI ! p, does not) -- so it is spotted here rather than asked of + * lib-vt, which reports us no such thing. */ + if (cbs->reset) cbs->reset(ud); + s->state = OS_GROUND; } else { s->state = c == 0x1b ? OS_ESC : OS_GROUND; } @@ -61,7 +396,7 @@ void osc_scan_feed(osc_scan_t *s, const uint8_t *data, size_t len, case OS_BODY: if (c == 0x07) { /* BEL terminator, as accepted for OSC 0/2 */ - if (!s->overflow) dispatch(s->buf, s->len, cb, ud); + if (!s->overflow) dispatch(s->buf, s->len, true, cbs, ud); s->state = OS_GROUND; } else if (c == 0x1b) { s->state = OS_BODY_ESC; @@ -76,12 +411,18 @@ void osc_scan_feed(osc_scan_t *s, const uint8_t *data, size_t len, case OS_BODY_ESC: if (c == '\\') { /* ST */ - if (!s->overflow) dispatch(s->buf, s->len, cb, ud); + if (!s->overflow) dispatch(s->buf, s->len, false, cbs, 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 if (c == 'c') { + /* `ESC c` where an ST was expected: the OSC is abandoned and this is + * a RIS, which is how lib-vt reads it too. Dropping it here instead + * would leave our records alive across a reset that happened. */ + if (cbs->reset) cbs->reset(ud); + s->state = OS_GROUND; } else { s->state = OS_GROUND; } @@ -110,6 +110,28 @@ struct pane { * field to it would change what every existing sender means. Orthogonal, so * a program can leave one line up and toggle the other. */ bool busy; + + /* OSC 7501 program status: the records this pane's program reports, and the + * one of them the status line above is currently showing. + * + * `status` and `busy` stay the single display slot and the single thing the + * control API reports, because every reader of them -- the frame, the + * sidebar, a script -- wants "what is this pane doing", not "which protocol + * said so". So 7501 composes into them, and the two sources are + * last-writer-wins: a 5577 `status`/`busy`/`clear` drops the records, rather + * than leaving `program_state` describing a record whose text is no longer + * on screen. + * + * Allocated on the first report. 64 records is the floor the spec allows a + * terminal to choose, and ~40KB is not something every pane should carry + * for a protocol most programs will never speak. */ + osc7501_t *ps; + uint64_t *ps_seen; /* when each record was last written, for LRU eviction */ + size_t nps; + uint64_t ps_clock; + bool ps_showing; /* `status`/`busy` are 7501's, not 5577's */ + osc7501_t ps_show; /* the record they came from, with `app` resolved */ + int64_t ps_reply_ms; /* last feature-detection answer, for its rate limit */ pane_button_t buttons[8]; size_t nbuttons; pane_osc_fn osc_cb; @@ -170,6 +192,233 @@ static void parse_buttons(pane_t *p, const char *payload) { p->dirty = true; } +/* ------------------------------------------- OSC 7501, program status ----- */ + +#define PANE_PS_MAX 64 + +const osc7501_t *pane_program_status(const pane_t *p) { + return p->ps_showing ? &p->ps_show : NULL; +} + +static int64_t mono_ms(void) { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (int64_t)ts.tv_sec * 1000 + ts.tv_nsec / 1000000; +} + +static void ps_remove_at(pane_t *p, size_t i) { + p->ps[i] = p->ps[p->nps - 1]; + p->ps_seen[i] = p->ps_seen[p->nps - 1]; + p->nps--; +} + +/* How much a record wants to be the one on screen. A root that says `working` + * while a child says `blocked` is two true things at once and the spec leaves + * the choice to us; we show the one that is waiting on a person, because that + * is the only one with an action attached to it. Ties go to whichever was + * written last. */ +static int ps_rank(ps_state_t s) { + switch (s) { + case PS_BLOCKED: return 5; + case PS_ERROR: return 4; + case PS_DONE: return 3; + case PS_WORKING: return 2; + case PS_IDLE: return 1; + default: return 0; + } +} + +/* Pick the record to show, resolve its `app` from the nearest ancestor that + * has one, and compose the status line out of it. */ +static void ps_recompose(pane_t *p) { + if (!p->nps) { + /* Nothing left. Only clear the line if it was ours: a pane that used 5577 + * before anything spoke 7501 keeps what it said. */ + if (p->ps_showing) { + p->status[0] = 0; + p->busy = false; + p->ps_showing = false; + p->dirty = true; + } + return; + } + + size_t best = 0; + for (size_t i = 1; i < p->nps; i++) { + int a = ps_rank(p->ps[i].state), b = ps_rank(p->ps[best].state); + if (a > b || (a == b && p->ps_seen[i] > p->ps_seen[best])) best = i; + } + + osc7501_t show = p->ps[best]; + if (!show.app[0]) { + /* "A record without app takes it from its nearest ancestor that has one": + * the longest id that is still a prefix of ours. */ + size_t pick = p->nps; + for (size_t i = 0; i < p->nps; i++) { + if (!p->ps[i].app[0] || !osc7501_is_descendant(show.id, p->ps[i].id)) + continue; + if (pick == p->nps || strlen(p->ps[i].id) > strlen(p->ps[pick].id)) + pick = i; + } + if (pick != p->nps) + snprintf(show.app, sizeof show.app, "%s", p->ps[pick].app); + } + + /* The status line is text, so the record has to become one. `title` names + * the record and `msg` says what it is doing -- a program with several of + * them sends both, and "US East: Pushing image" is the only reading of that + * pair which survives being one line. With neither, the state word is all + * there is to say, and saying it beats a blank row under a spinner. */ + char line[256]; + const char *msg = show.msg[0] ? show.msg : NULL; + const char *title = show.title[0] ? show.title : NULL; + if (title && msg) + snprintf(line, sizeof line, "%s: %s", title, msg); + else if (msg || title) + snprintf(line, sizeof line, "%s", msg ? msg : title); + else + snprintf(line, sizeof line, "%s", osc7501_state_name(show.state)); + + if (show.progress >= 0) { + size_t n = strlen(line); + if (n + 6 < sizeof line) + snprintf(line + n, sizeof line - n, " %d%%", show.progress); + } + + /* `blocked` is busy: the question is what the status describes, and it is + * still the case until somebody answers it. `done` and `error` are not -- + * they describe something that has stopped. */ + bool busy = show.state == PS_WORKING || show.state == PS_BLOCKED; + if (strcmp(p->status, line) != 0 || p->busy != busy || !p->ps_showing) + p->dirty = true; + snprintf(p->status, sizeof p->status, "%s", line); + p->busy = busy; + p->ps_show = show; + p->ps_showing = true; +} + +/* Remove a record and everything under it; no id removes every record. */ +static void ps_clear(pane_t *p, const char *id) { + for (size_t i = 0; i < p->nps;) { + bool hit = !*id || strcmp(p->ps[i].id, id) == 0 || + osc7501_is_descendant(p->ps[i].id, id); + if (hit) + ps_remove_at(p, i); + else + i++; + } +} + +/* The lifetime rule the protocol is built on: no heartbeat, so a `working` or + * `blocked` record lives until the terminal sees the program stop or a new + * shell prompt begin. `done` and `error` survive both -- they are the whole + * point of walking away from a pane -- and `idle` may go either way, so it + * stays: a program at rest that is still running has not stopped being at + * rest because it printed a prompt. */ +static void ps_drop_transient(pane_t *p) { + for (size_t i = 0; i < p->nps;) { + if (p->ps[i].state == PS_WORKING || p->ps[i].state == PS_BLOCKED) + ps_remove_at(p, i); + else + i++; + } + ps_recompose(p); +} + +static void ps_drop_all(pane_t *p) { + p->nps = 0; + ps_recompose(p); +} + +static void on_program_status(const osc7501_t *rep, void *ud) { + pane_t *p = ud; + + if (rep->state == PS_CLEAR) { + if (!p->ps) return; + ps_clear(p, rep->id); + ps_recompose(p); + return; + } + + if (!p->ps) { + p->ps = calloc(PANE_PS_MAX, sizeof *p->ps); + p->ps_seen = calloc(PANE_PS_MAX, sizeof *p->ps_seen); + if (!p->ps || !p->ps_seen) { + free(p->ps); + free(p->ps_seen); + p->ps = NULL; + p->ps_seen = NULL; + return; + } + } + + size_t at = p->nps; + for (size_t i = 0; i < p->nps; i++) + if (strcmp(p->ps[i].id, rep->id) == 0) at = i; + + if (at == p->nps) { + if (p->nps == PANE_PS_MAX) { + /* Full: the least recently written record goes, so a program that keeps + * reporting keeps its records and one that invents ids cannot push the + * live ones out. */ + size_t old = 0; + for (size_t i = 1; i < p->nps; i++) + if (p->ps_seen[i] < p->ps_seen[old]) old = i; + at = old; + } else { + at = p->nps++; + } + } + + /* A report replaces its record entirely: a key it does not carry is a key + * the record no longer has. */ + p->ps[at] = *rep; + p->ps_seen[at] = ++p->ps_clock; + ps_recompose(p); +} + +/* Feature detection. The spec's reply is byte-identical to the query, which is + * exactly the shape that cost us four megabytes in a second and a half when + * `hello` answered with `hello` (see below): a pane with echo on -- `cat`, + * `tee`, a shell, an ssh hop to another supporting terminal -- sends our + * answer straight back, and it parses as another question. + * + * So this one cannot be fixed by naming the reply differently, and is + * rate-limited instead. One answer per quarter second bounds the echo at ~44 + * bytes a second, while any real program -- which asks once, at startup, and + * waits -- is answered immediately. */ +static void on_program_status_query(bool bel, void *ud) { + pane_t *p = ud; + int64_t now = mono_ms(); + if (p->ps_reply_ms && now - p->ps_reply_ms < 250) return; + p->ps_reply_ms = now; + /* The terminator the program asked with: it is matching on bytes it chose, + * and a shell script reaches for BEL because a trailing `ESC \` inside a + * double-quoted string escapes the quote. */ + const char *reply = bel ? "\033]7501;?\007" : "\033]7501;?\033\\"; + pane_write(p, reply, strlen(reply)); +} + +static void on_shell_prompt(void *ud) { ps_drop_transient((pane_t *)ud); } + +static void on_full_reset(void *ud) { + pane_t *p = ud; + /* RIS clears 7501's records. It is left to mean nothing for 5577, whose + * status is cleared by `clear` and by the program exiting, and which was + * never tied to the terminal's own reset. */ + if (p->ps) ps_drop_all(p); +} + +static void on_osc5577(const char *verb, const char *payload, void *ud); + +static const osc_scan_cbs_t PANE_OSC_CBS = { + .verb = on_osc5577, + .status = on_program_status, + .status_query = on_program_status_query, + .prompt = on_shell_prompt, + .reset = on_full_reset, +}; + static void on_osc5577(const char *verb, const char *payload, void *ud) { pane_t *p = ud; @@ -185,6 +434,21 @@ static void on_osc5577(const char *verb, const char *payload, void *ud) { size_t vlen = strlen(verb); if (vlen >= 6 && strcmp(verb + vlen - 6, "-reply") == 0) return; + /* 5577 and 7501 share one status slot, last writer wins -- so taking the + * slot takes the records with it. Dropping them rather than leaving them + * behind is the point: `program_state` describing a record whose text is no + * longer on screen is worse than no `program_state` at all. */ + if (p->ps && (strcmp(verb, "status") == 0 || strcmp(verb, "busy") == 0 || + strcmp(verb, "clear") == 0)) { + p->nps = 0; + /* The flag goes with them. 5577's `status` deliberately leaves `busy` + * alone -- the two are orthogonal there -- but a `busy` that came from a + * 7501 record we just dropped belongs to nobody, and would leave a + * spinner turning over somebody else's text. */ + if (p->ps_showing) p->busy = false; + p->ps_showing = false; + } + if (strcmp(verb, "status") == 0) { snprintf(p->status, sizeof p->status, "%s", payload); p->dirty = true; @@ -439,6 +703,8 @@ void pane_free(pane_t *p) { free(p->argv); } free(p->cwd); + free(p->ps); + free(p->ps_seen); if (p->pty.fd >= 0) pty_close(&p->pty); if (p->mev) ghostty_mouse_event_free(p->mev); if (p->menc) ghostty_mouse_encoder_free(p->menc); @@ -683,6 +949,11 @@ static void pane_died(pane_t *p) { if (!p->alive) return; p->alive = false; p->dirty = true; + /* The program stopped, so what it said it was *doing* stopped being true. + * What it said it had finished did not, which is why a `done` record (and + * the line composed from it) outlives the thing that reported it -- the one + * case where a status left behind by a dead program is not a lie. */ + if (p->ps) ps_drop_transient(p); for (int i = 0; p->pty.pid > 0 && i < 5; i++) { int st = 0; @@ -716,6 +987,9 @@ bool pane_restart(pane_t *p) { p->status[0] = 0; p->busy = false; p->nbuttons = 0; + p->nps = 0; + p->ps_showing = false; + p->ps_reply_ms = 0; osc_scan_reset(&p->scan); p->exit_known = false; p->exit_signaled = false; @@ -757,7 +1031,7 @@ ssize_t pane_pump(pane_t *p) { if (n > 0) { /* 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); + osc_scan_feed(&p->scan, buf, (size_t)n, &PANE_OSC_CBS, p); ghostty_terminal_vt_write(p->term, buf, (size_t)n); p->dirty = true; total += n; |
