aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoruser <user@clank>2026-10-09 12:24:48 +0200
committeruser <user@clank>2026-10-09 12:31:53 +0200
commitfcf305085343bcb5c5240ed642468461f247f1ed (patch)
treeda00e12658bc22fee0e9f25e634572cf2aec9b53
parenttab bar: a sidebar that fits the terminal, and gives up the edge last (diff)
downloadslosh-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.
-rw-r--r--.agents/skills/driving-slosh/SKILL.md35
-rw-r--r--docs/panes.md5
-rw-r--r--docs/scripting.md64
-rw-r--r--docs/slosh.54
-rw-r--r--include/osc5577.h111
-rw-r--r--include/slosh.h8
-rw-r--r--src/app.c15
-rw-r--r--src/osc5577.c351
-rw-r--r--src/pane.c276
-rw-r--r--tests/fuzz/corpus/osc5577/lifetime1
-rw-r--r--tests/fuzz/corpus/osc5577/status_75011
-rw-r--r--tests/fuzz/corpus/osc5577/status_7501_hostile1
-rw-r--r--tests/fuzz/corpus/osc5577/status_7501_query1
-rw-r--r--tests/fuzz/corpus/osc5577/status_7501_tree1
-rw-r--r--tests/fuzz/fuzz_osc5577.c48
-rw-r--r--tests/test_osc7501.py635
16 files changed, 1540 insertions, 17 deletions
diff --git a/.agents/skills/driving-slosh/SKILL.md b/.agents/skills/driving-slosh/SKILL.md
index ebf5a7d..833664e 100644
--- a/.agents/skills/driving-slosh/SKILL.md
+++ b/.agents/skills/driving-slosh/SKILL.md
@@ -306,11 +306,42 @@ click arrives **on your stdin** as:
which is how you ask a question in place instead of printing a prompt and hoping
somebody is looking. Button ids are `[A-Za-z0-9_-]`, 1 to 32 characters.
-Two rules:
+**If you already speak OSC 7501, use that instead.** It is the
+[program status protocol](https://www.superlogical.com/rex/docs/build/program-status),
+implemented by other terminals too, so the same reporting works when you are not
+running in slosh:
+
+```bash
+b64() { printf '%s' "$1" | base64 | tr -d '\n'; }
+printf '\e]7501;state=working:app=claude-code:msg=%s\e\\' "$(b64 'running the test suite')"
+printf '\e]7501;state=blocked:kind=question:msg=%s\e\\' "$(b64 'Which branch should I target?')"
+printf '\e]7501;state=done:msg=%s\e\\' "$(b64 '14 tests, all green')"
+printf '\e]7501;state=clear\e\\'
+```
+
+`state` is required: `idle`, `working`, `done`, `blocked`, `error`, or `clear`.
+`msg` and `title` are base64; `app` is your own stable name; `progress` is
+0–100; `kind` (`permission`, `question`, `auth`) goes with `blocked`; `id`
+reports more than one thing at once (`build/test` is a child of `build`, and
+clearing a parent clears its children). It lands in the same status line and
+sidebar row as `5577;1;status`, and `panes` reports `program_state`,
+`program_kind`, `program_app` and `progress` beside `status` and `busy`.
+
+Why you would bother: **`done` and `blocked` say what a line of text cannot.**
+A `working` record is dropped when you exit or your shell prints a new prompt,
+but `done` and `error` survive — so "finished, and nobody has looked yet" is a
+state the session keeps for the human, which is exactly what you want when you
+have been working in a pane nobody is watching. Report `done` or `error` right
+before you exit.
+
+Three rules:
- **A purpose you set this way is in-band, and loses to a declared one.** If the
pane was tagged by a layout or an operator, your `purpose` is refused. Read
`panes` if you need to know what you are called.
+- **The two protocols share one status line, last writer wins.** Pick one and
+ stay with it: a 5577 `status` drops the 7501 records, and a 7501 report takes
+ the line back.
- **Never treat a reply as a request.** Everything the session sends back ends in
`-reply` (`hello-reply`, `shader-reply`). If you echo what you are sent — a
REPL, `cat`, a shell with echo on — do not answer it.
@@ -329,7 +360,7 @@ Two rules:
| verb | takes |
|---|---|
-| `panes` `tabs` | —. ids, rects, titles, purposes, `alive`, `exit_code`, `pid`, `tab_id`, `purpose_declared`, `floating`, `hidden`, `suspended`, `status`, `busy` |
+| `panes` `tabs` | —. ids, rects, titles, purposes, `alive`, `exit_code`, `pid`, `tab_id`, `purpose_declared`, `floating`, `hidden`, `suspended`, `status`, `busy`, `program_state`, `program_kind`, `program_app`, `progress` |
| `snapshot` | `format:"text"` for text, `"bytes"` for the frame's own output (a second call is the delta), omitted for JSON |
| `capture` | `id` (0 = focused). One pane's visible text, from any tab, with no side effects |
| `send` | `data` — session input, always to the focused pane |
diff --git a/docs/panes.md b/docs/panes.md
index 6a4b586..91f1968 100644
--- a/docs/panes.md
+++ b/docs/panes.md
@@ -105,6 +105,7 @@ as one figure, the same way a divider running into the ring does. Set
**Under each tab, the sidebar says what the panes inside are up to.** A pane
that announced a status over [OSC 5577](scripting.md#a-pane-can-draw-its-own-chrome)
+or [OSC 7501](scripting.md#or-say-what-it-is-doing-in-somebody-elses-protocol)
puts that line under its tab — visible from every other tab, which is the
point — and a pane that died shows how instead (`exited: status 3`). At most
`tab_bar_status` rows per tab (3 by default, 0 turns it off); more than that
@@ -126,7 +127,9 @@ after it. One frame
in `busy_mark` is a static mark and costs nothing, several make it turn, and the
session only keeps a frame clock while such a row is actually on screen. The
flag dies with the program: a status left behind by something that exited
-describes the past.
+describes the past. A program reporting over OSC 7501 gets the same spinner for
+`working` and for `blocked` — a question waiting on you is still happening — and
+none for `done` or `error`, which are not.
**`tab_bar_padding` is the air inside the strip**, between its frame and the
list: 1, 2 or 4 values in CSS order, the shape [`padding`](config.md) takes for
diff --git a/docs/scripting.md b/docs/scripting.md
index 74c36d4..2b484ef 100644
--- a/docs/scripting.md
+++ b/docs/scripting.md
@@ -183,6 +183,70 @@ it is sent (`cat`, a shell with echo on, a REPL waiting for a line) would
otherwise be answered into a loop, which is exactly what `hello` used to do:
4 MB of hellos in a second and a half.
+## ...or say what it is doing in somebody else's protocol
+
+OSC 5577 only works for programs that know slosh exists. **OSC 7501, the
+[program status protocol](https://www.superlogical.com/rex/docs/build/program-status),
+is the same idea written down for everybody** — by the author of ghostty, and
+implemented by it and by Rex — so a program that already speaks it needs to know
+nothing about us:
+
+```bash
+status() {
+ printf '\033]7501;state=%s:msg=%s\033\\' "$1" "$(printf '%s' "$2" | base64 | tr -d '\n')"
+}
+status working "Syncing photos"
+rsync -a ~/Photos backup:/photos && status done "Photos synced" || status error "rsync failed"
+```
+
+The body is `key=value` pairs separated by `:`. `state` is the only required
+key, and is one of `idle`, `working`, `done`, `blocked`, `error`, or `clear` to
+remove the record. The rest: `msg` and `title` (base64 UTF-8), `app` (a stable
+name like `cargo`), `progress` (0–100), `kind` (`permission`, `question` or
+`auth`, with `blocked`), and `id` to report more than one thing at a time.
+
+It lands in the same place 5577's `status` does — the pane's frame, a row in the
+sidebar, `status` and `busy` in `panes` — because every reader of a pane wants
+"what is this doing", not "which protocol said so". What the structure buys on
+top of that is reported alongside: `program_state`, `program_kind`,
+`program_app` and `progress`.
+
+```bash
+$ slosh cmd '{"cmd":"panes"}' | jq -c '.panes[] | select(.program_state == "blocked")'
+{"id":4,...,"status":"EU West: Approve deploy to production?","busy":true,
+ "program_state":"blocked","program_kind":"permission","program_app":"deploy","progress":-1}
+```
+
+The two protocols share that one slot, **last writer wins**: a 5577
+`status`/`busy`/`clear` drops the 7501 records, because a `program_state`
+describing text that is no longer on screen is worse than none. Neither
+protocol replaces the other — 7501 is deliberately one-way (a program reports,
+the terminal decides what to show), so `buttons`, `click` and `shader` have
+nowhere to live in it; and 5577 has no idea what `done` or `blocked` mean.
+
+**A program with several things going at once** gives each an `id`, which may be
+a path: `build/test` is a child of `build`. Clearing a record clears everything
+under it, a record with no `app` inherits one from its nearest ancestor that has
+one, and when several records are true at the same time the pane shows the one
+nearest to needing a person: `blocked`, then `error`, `done`, `working`, `idle`,
+ties going to whichever was written last. So a deploy that is `working` at the
+root while `eu-west` waits for approval shows the approval.
+
+**Records die without anybody saying so**, because there is no heartbeat in this
+protocol and a status nobody retracted is the failure mode it exists to fix. A
+`working` or `blocked` record is dropped when the program exits or a new shell
+prompt begins (`OSC 133 ; A`); `done` and `error` survive both, which is how a
+result is still there when you come back to the pane; `idle` stays until it is
+replaced; and a full reset (`ESC c`) removes all of them.
+
+To find out whether any of this is listened to, ask: `printf
+'\033]7501;?\033\\'` is answered with the same bytes, `\033]7501;?\033\\`, and
+by nothing at all in a terminal that does not implement it. That reply *is* a
+request — the one shape 5577 forbids — so it is rate-limited to one answer per
+250 ms per pane: a program that asks once is answered at once, and a pane that
+echoes our answer back into our own scanner stops there instead of trading
+megabytes with us.
+
## Driving it from an agent
Everything above is what an agent needs, and none of it says which parts matter.
diff --git a/docs/slosh.5 b/docs/slosh.5
index 6226cf9..5fab826 100644
--- a/docs/slosh.5
+++ b/docs/slosh.5
@@ -192,8 +192,8 @@ panes keep the same rectangle whatever it is. Sidebars only.
.TP
.BI tab_bar_status\ n
Under each of a sidebar's tabs, up to \fIn\fR rows of what the panes inside
-said through OSC 5577 status \(em or how they died \(em with an ellipsis when
-there was more. Clicking a status row jumps to the pane that said it.
+said through OSC 5577 status or OSC 7501 program status \(em or how they died
+\(em with an ellipsis when there was more. Clicking a status row jumps to the pane that said it.
0 turns it off; \fBtop\fR has no room for it either way. The statuses give
way before the tabs do when the sidebar is short of rows: the list is how you
navigate, and a talkative pane must not cost you a tab you can click.
diff --git a/include/osc5577.h b/include/osc5577.h
index e6033f1..11c68f3 100644
--- a/include/osc5577.h
+++ b/include/osc5577.h
@@ -1,4 +1,11 @@
-/* OSC 5577: the pane status bar and action buttons.
+/* The OSC side-channel scanner: OSC 5577 (ours), OSC 7501 (the program status
+ * protocol), OSC 133 ; A and RIS.
+ *
+ * One scanner, because all four want the same thing -- a look at every byte a
+ * pane's program writes, before lib-vt throws away the sequences it does not
+ * know -- and a pane should pay for that scan once.
+ *
+ * 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:
@@ -22,6 +29,30 @@
* 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.
+ *
+ * OSC 7501, the program status protocol, is the other half of this file:
+ *
+ * printf '\033]7501;state=working:app=cargo:msg=YnVpbGRpbmc=\033\\'
+ *
+ * Same problem as 5577's `status` and `busy`, solved by somebody else for
+ * everybody: a program says what it is doing, in a structured way, and the
+ * terminal decides how to show it. We implement it *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 can absorb the other. What 7501 buys is the programs we do
+ * not control: an agent, cargo, brew or terraform that already speaks it needs
+ * to know nothing about slosh.
+ *
+ * Spec: https://www.superlogical.com/rex/docs/build/program-status (rev 0.2).
+ * Where it says MUST, the comments below say which line is doing it.
+ *
+ * Upstream libghostty-vt implements this protocol too, *newer than our pin*:
+ * `GHOSTTY_TERMINAL_OPT_PROGRAM_STATUS` hands over a parsed report and leaves
+ * the records to the host, which is the same split as here. On a vendor bump
+ * this file's 7501 half is replaceable by that effect -- and must be, because
+ * lib-vt answers the support query itself through `write_pty` and does not
+ * rate-limit it. See "What upstream did" under D1 in DESIGN.md before
+ * re-vendoring.
*/
#ifndef SLOSH_OSC5577_H
#define SLOSH_OSC5577_H
@@ -32,6 +63,46 @@
#define OSC5577_MAX 4096
+/* 7501's own cap is 4096 bytes for the whole sequence, OSC through ST. Our
+ * buffer holds the body only, so the four bytes of `ESC ]` and `ESC \` come
+ * off here rather than being silently allowed. */
+#define OSC7501_MAX_BODY (4096 - 4)
+
+/* How much of a `msg` or `title` we keep. The spec's limits (2048 and 192
+ * bytes decoded) are what a report may *contain* and are enforced whole --
+ * an over-long one is discarded, not trimmed -- but what we store is the slot
+ * a pane status has always had. "Terminals MAY shorten it." */
+#define OSC7501_KEEP 256
+
+typedef enum {
+ PS_NONE = 0, /* no record */
+ PS_IDLE,
+ PS_WORKING,
+ PS_DONE,
+ PS_BLOCKED,
+ PS_ERROR,
+ PS_CLEAR, /* not a state: removes the record and everything under it */
+} ps_state_t;
+
+typedef enum {
+ PS_KIND_NONE = 0,
+ PS_KIND_PERMISSION,
+ PS_KIND_QUESTION,
+ PS_KIND_AUTH,
+} ps_kind_t;
+
+/* One parsed report, which is also what a record holds: every key of the last
+ * report that used that id, and nothing from any earlier one. */
+typedef struct {
+ ps_state_t state;
+ ps_kind_t kind; /* blocked only */
+ int progress; /* 0..100, or -1 for unknown */
+ char id[129]; /* "" is the root record */
+ char app[33]; /* resolved from an ancestor when a record has none */
+ char title[OSC7501_KEEP];
+ char msg[OSC7501_KEEP];
+} osc7501_t;
+
typedef struct {
enum { OS_GROUND, OS_ESC, OS_BODY, OS_BODY_ESC } state;
char buf[OSC5577_MAX];
@@ -42,9 +113,45 @@ typedef struct {
/* verb and payload are NUL-terminated and valid for the callback only. */
typedef void (*osc5577_fn)(const char *verb, const char *payload, void *ud);
+/* What one pass over a pane's output can turn up. Every field may be NULL.
+ *
+ * `status_query` is 7501's feature detection, which a terminal answers with
+ * the bytes it was sent. That is the one shape this file otherwise forbids --
+ * see the note on replies above -- so the handler, not the scanner, is where
+ * the loop is broken (pane.c rate-limits it).
+ *
+ * `prompt` (OSC 133 ; A) and `reset` (RIS, `ESC c`) are here because 7501 ties
+ * record lifetime to them: a new shell prompt or a full reset is how a stale
+ * `working` record dies without a heartbeat. */
+typedef struct {
+ void (*verb)(const char *verb, const char *payload, void *ud); /* 5577 */
+ void (*status)(const osc7501_t *rep, void *ud); /* 7501 */
+ /* 7501 ; ? -- `bel` is true when the program ended its query with BEL
+ * rather than ST, which is the terminator the answer has to use. */
+ void (*status_query)(bool bel, void *ud);
+ void (*prompt)(void *ud); /* 133 ; A */
+ void (*reset)(void *ud); /* RIS */
+} osc_scan_cbs_t;
+
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);
+ const osc_scan_cbs_t *cbs, void *ud);
+
+/* Parse the pairs after `OSC 7501 ;`. False means the report is discarded
+ * whole -- no state, an unknown state, a broken limit, base64 that does not
+ * decode, a control character in decoded text, a malformed id -- which is the
+ * spec's rule and the reason nothing here writes through a partial report.
+ * A malformed *pair* is skipped and the rest still parsed. */
+bool osc7501_parse(const char *pairs, size_t len, osc7501_t *out);
+/* "idle", "working", "done", "blocked", "error", or "" for no record.
+ * PS_CLEAR has no name: it never reaches a record. */
+const char *osc7501_state_name(ps_state_t s);
+/* "permission", "question", "auth", or "". */
+const char *osc7501_kind_name(ps_kind_t k);
+/* Whether `id` is `child` or `child/of` of `parent` ("" is the root, and the
+ * ancestor of everything). Clearing and app inheritance are the only two
+ * things the spec lets the hierarchy mean. */
+bool osc7501_is_descendant(const char *id, const char *parent);
/* %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);
diff --git a/include/slosh.h b/include/slosh.h
index 5d55629..92e563d 100644
--- a/include/slosh.h
+++ b/include/slosh.h
@@ -8,6 +8,9 @@
#include <sys/types.h>
#include "input.h"
+/* osc7501_t: a pane carries the program status its program last reported, and
+ * both the control API and the drawing code read it. */
+#include "osc5577.h"
/* cell_t, color_t and the ATTR_* flags live in the shader ABI, because that is
* the one place they are a *contract* with code compiled outside this tree. */
#include "shader_abi.h"
@@ -330,6 +333,11 @@ const char *pane_status(const pane_t *p);
* status left behind by something that exited is a description of the past,
* whatever it last claimed. */
bool pane_status_busy(const pane_t *p);
+/* The OSC 7501 record the status above was composed from, or NULL when no
+ * program in this pane has reported one (or 5577 has since taken the slot:
+ * they share it, last writer wins). `app` is resolved from the record's
+ * nearest ancestor that has one, the way the protocol asks. */
+const osc7501_t *pane_program_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);
diff --git a/src/app.c b/src/app.c
index a6dedd7..bcb4f88 100644
--- a/src/app.c
+++ b/src/app.c
@@ -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;
}
diff --git a/src/pane.c b/src/pane.c
index d1b4f66..7cf01d2 100644
--- a/src/pane.c
+++ b/src/pane.c
@@ -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;
diff --git a/tests/fuzz/corpus/osc5577/lifetime b/tests/fuzz/corpus/osc5577/lifetime
new file mode 100644
index 0000000..3af5715
--- /dev/null
+++ b/tests/fuzz/corpus/osc5577/lifetime
@@ -0,0 +1 @@
+]7501;state=working:msg=Y29tcGlsaW5n\]133;A\c \ No newline at end of file
diff --git a/tests/fuzz/corpus/osc5577/status_7501 b/tests/fuzz/corpus/osc5577/status_7501
new file mode 100644
index 0000000..c9b4621
--- /dev/null
+++ b/tests/fuzz/corpus/osc5577/status_7501
@@ -0,0 +1 @@
+]7501;state=working:app=cargo:progress=40:msg=YnVpbGRpbmc=\ \ No newline at end of file
diff --git a/tests/fuzz/corpus/osc5577/status_7501_hostile b/tests/fuzz/corpus/osc5577/status_7501_hostile
new file mode 100644
index 0000000..38924d2
--- /dev/null
+++ b/tests/fuzz/corpus/osc5577/status_7501_hostile
@@ -0,0 +1 @@
+]7501;msg=bm8gc3RhdGU=\]7501;state=exploded\]7501;state=working:msg=not..base64\]7501;state=working:msg=G101NTc3OzE7c3RhdHVzO293bmVkG1w=\]7501;state=working:id=a//b:keylongerthansixteen=1\]7501;state=done:msg=c3RpbGwgcmVhZA==\ \ No newline at end of file
diff --git a/tests/fuzz/corpus/osc5577/status_7501_query b/tests/fuzz/corpus/osc5577/status_7501_query
new file mode 100644
index 0000000..8f7b063
--- /dev/null
+++ b/tests/fuzz/corpus/osc5577/status_7501_query
@@ -0,0 +1 @@
+]7501;?\ \ No newline at end of file
diff --git a/tests/fuzz/corpus/osc5577/status_7501_tree b/tests/fuzz/corpus/osc5577/status_7501_tree
new file mode 100644
index 0000000..5775305
--- /dev/null
+++ b/tests/fuzz/corpus/osc5577/status_7501_tree
@@ -0,0 +1 @@
+]7501;state=working:app=deploy:msg=RGVwbG95aW5nIHYyLjQuMQ==\]7501;state=blocked:kind=permission:id=eu-west:title=RVUgV2VzdA==:msg=QXBwcm92ZSBkZXBsb3kgdG8gcHJvZHVjdGlvbj8=\]7501;state=clear:id=eu-west\ \ No newline at end of file
diff --git a/tests/fuzz/fuzz_osc5577.c b/tests/fuzz/fuzz_osc5577.c
index 3b197fb..cdcc3cd 100644
--- a/tests/fuzz/fuzz_osc5577.c
+++ b/tests/fuzz/fuzz_osc5577.c
@@ -1,10 +1,15 @@
-/* Fuzz target: the OSC 5577 scanner (src/osc5577.c).
+/* Fuzz target: the OSC side-channel scanner (src/osc5577.c).
*
* This scans every byte a pane's program writes, so its input is the least
* trusted stream in the whole session. Feed the bytes twice -- once whole,
* once split at a data-chosen boundary so partial-sequence state is
* exercised -- and in the callback run the payload through the unescaper at
* several output capacities plus the id validator, exactly as pane.c would.
+ *
+ * OSC 7501 rides the same scanner, and its parser does the one thing 5577's
+ * never had to: base64, with length limits either side of the decode. So the
+ * report parser is also called directly on the raw input, which reaches body
+ * shapes the scanner's own framing would never hand it.
*/
#include "osc5577.h"
@@ -22,17 +27,52 @@ static void on_seq(const char *verb, const char *payload, void *ud) {
(void)osc5577_unescape(payload, plen, out, 7);
}
+static void on_status(const osc7501_t *rep, void *ud) {
+ (void)ud;
+ /* Everything a record holds has to be a C string the rest of the session
+ * can print: the parser's own contract, checked where a fuzzer can see it
+ * break. */
+ if (strlen(rep->id) >= sizeof rep->id) __builtin_trap();
+ if (strlen(rep->app) >= sizeof rep->app) __builtin_trap();
+ if (strlen(rep->msg) >= sizeof rep->msg) __builtin_trap();
+ if (strlen(rep->title) >= sizeof rep->title) __builtin_trap();
+ if (rep->progress < -1 || rep->progress > 100) __builtin_trap();
+ (void)osc7501_state_name(rep->state);
+ (void)osc7501_kind_name(rep->kind);
+ (void)osc7501_is_descendant(rep->id, "");
+ (void)osc7501_is_descendant(rep->id, rep->id);
+}
+
+static void on_nothing(void *ud) { (void)ud; }
+
+static void on_query(bool bel, void *ud) {
+ (void)bel;
+ (void)ud;
+}
+
+static const osc_scan_cbs_t CBS = {
+ .verb = on_seq,
+ .status = on_status,
+ .status_query = on_query,
+ .prompt = on_nothing,
+ .reset = on_nothing,
+};
+
int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
osc_scan_t s;
osc_scan_reset(&s);
- osc_scan_feed(&s, data, size, on_seq, NULL);
+ osc_scan_feed(&s, data, size, &CBS, NULL);
/* again, split so a sequence straddles the feed boundary */
osc_scan_reset(&s);
size_t cut = size ? (size_t)(data[0] * (size - 1) / 255) : 0;
- osc_scan_feed(&s, data, cut, on_seq, NULL);
- osc_scan_feed(&s, data + cut, size - cut, on_seq, NULL);
+ osc_scan_feed(&s, data, cut, &CBS, NULL);
+ osc_scan_feed(&s, data + cut, size - cut, &CBS, NULL);
+
+ /* the report parser on its own, with no framing in the way */
+ osc7501_t rep;
+ if (osc7501_parse((const char *)data, size, &rep)) on_status(&rep, NULL);
return 0;
}
diff --git a/tests/test_osc7501.py b/tests/test_osc7501.py
new file mode 100644
index 0000000..b1d986e
--- /dev/null
+++ b/tests/test_osc7501.py
@@ -0,0 +1,635 @@
+#!/usr/bin/env python3
+"""OSC 7501 — the program status protocol, as a pane's status line.
+
+The sequence a program sends is somebody else's specification
+(https://www.superlogical.com/rex/docs/build/program-status, rev 0.2), which
+makes this file the place where our reading of it is written down: what a
+record is, which one gets the one line a pane has, when a record dies without
+anybody saying so, and what a hostile report may not do.
+
+The companion to test_osc5577.py, not a replacement for it: the two protocols
+share the status slot and the tests next door own the other half of it.
+"""
+
+import base64
+import sys
+import tempfile
+import time
+
+from harness import Session, check, report
+
+
+def cfg(text):
+ f = tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False)
+ f.write(text)
+ f.close()
+ return f.name
+
+
+def b64(text):
+ return base64.b64encode(text.encode()).decode()
+
+
+def shell():
+ """A pane that evaluates lines we send it, without a line editor.
+
+ Same reason as test_osc5577.py: readline eats a typed ESC as a meta
+ prefix, so `printf "<ESC>]7501;..."` never reaches printf.
+ """
+ return ["/bin/sh", "-c", 'stty raw -echo; while IFS= read -r l; do eval "$l"; done']
+
+
+def cmd(body):
+ r"""A shell line, typed at a pane, that prints one OSC sequence.
+
+ The driver's unescaper reads `\e` as ESC and `\0` as NUL — so `\033` here
+ would arrive as NUL + "33" and printf would emit no ESC at all, which is
+ how a RIS test once passed without ever resetting anything.
+ """
+ return 'printf "\\e]%s\\e\\\\\\\\"' % body.replace("%", "%%") + "\\n"
+
+
+def emit(*seqs):
+ """A pane that prints sequences and then echoes everything it is sent."""
+ body = "".join('printf "%s";' % s.replace("%", "%%") for s in seqs)
+ return ["/bin/sh", "-c", body + " stty raw -echo; cat -v"]
+
+
+FIELDS = ("status", "busy", "program_state", "program_kind", "program_app", "progress")
+
+
+def state(s):
+ return {k: s.panes()[0][k] for k in FIELDS}
+
+
+def test_a_report_becomes_the_pane_status():
+ with Session(shell(), cols=70, rows=8) as s:
+ s.settle()
+ p = s.pane()
+ bottom = lambda: s.snapshot().line(p["y"] + p["h"] - 1)
+
+ s.raw(cmd("7501;state=working:app=cargo:progress=40:msg=" + b64("building")))
+ s.settle()
+ check(
+ "the message is drawn in the frame", "building" in bottom(), repr(bottom())
+ )
+ check("progress rides along with it", "40%" in bottom(), repr(bottom()))
+ check(
+ "the sequence itself reaches no screen",
+ "7501" not in s.snapshot().screen(),
+ repr(s.snapshot().screen()[:120]),
+ )
+
+ st = state(s)
+ check("`working` is busy", st["busy"], str(st))
+ check("the state is reported", st["program_state"] == "working", str(st))
+ check("so is the program's own name", st["program_app"] == "cargo", str(st))
+ check("and the progress", st["progress"] == 40, str(st))
+ check(
+ "`status` is the one answer either protocol gives",
+ st["status"] == "building 40%",
+ str(st),
+ )
+
+
+def test_the_five_states():
+ """Only two of them are something still happening. `done` and `error`
+ describe work that stopped, and a spinner over either is a lie."""
+ with Session(shell(), cols=70, rows=8) as s:
+ s.settle()
+ for name, busy in (
+ ("idle", False),
+ ("working", True),
+ ("blocked", True),
+ ("done", False),
+ ("error", False),
+ ):
+ s.raw(cmd("7501;state=%s:msg=%s" % (name, b64("the " + name + " one"))))
+ s.settle()
+ st = state(s)
+ check(
+ "`%s` is %sbusy" % (name, "" if busy else "not "),
+ st["busy"] == busy and st["program_state"] == name,
+ str(st),
+ )
+
+ s.raw(cmd("7501;state=blocked:kind=question:msg=" + b64("Which branch?")))
+ s.settle()
+ check(
+ "`kind` says what it is blocked on",
+ state(s)["program_kind"] == "question",
+ str(state(s)),
+ )
+
+ # kind means nothing anywhere else, and must not survive into a state
+ # that cannot carry it
+ s.raw(cmd("7501;state=working:kind=question:msg=" + b64("on")))
+ s.settle()
+ check(
+ "`kind` is ignored outside `blocked`",
+ state(s)["program_kind"] == "",
+ str(state(s)),
+ )
+
+ s.raw(cmd("7501;state=done:progress=50:msg=" + b64("finished")))
+ s.settle()
+ check(
+ "`progress` is ignored outside `working`/`blocked`",
+ state(s)["progress"] == -1,
+ str(state(s)),
+ )
+
+
+def test_a_report_replaces_its_record_whole():
+ """A key the report does not carry is a key the record no longer has --
+ which is why the spec tells programs to repeat `app` every time."""
+ with Session(shell(), cols=70, rows=8) as s:
+ s.settle()
+ s.raw(cmd("7501;state=working:app=brew:msg=" + b64("installing")))
+ s.settle()
+ check("app arrived", state(s)["program_app"] == "brew", str(state(s)))
+
+ s.raw(cmd("7501;state=working:msg=" + b64("still installing")))
+ s.settle()
+ st = state(s)
+ check(
+ "a report without app leaves none behind", st["program_app"] == "", str(st)
+ )
+ check(
+ "...and the new message is the one shown",
+ st["status"] == "still installing",
+ str(st),
+ )
+
+
+def test_several_records():
+ with Session(shell(), cols=78, rows=8) as s:
+ s.settle()
+ p = s.pane()
+ bottom = lambda: s.snapshot().line(p["y"] + p["h"] - 1)
+
+ s.raw(cmd("7501;state=working:app=deploy:msg=" + b64("Deploying v2.4.1")))
+ s.raw(
+ cmd(
+ "7501;state=working:id=us-east:title=%s:progress=40:msg=%s"
+ % (b64("US East"), b64("Pushing image"))
+ )
+ )
+ s.settle()
+ check(
+ "a child record is shown with its title",
+ "US East: Pushing image" in bottom(),
+ repr(bottom()),
+ )
+
+ s.raw(
+ cmd(
+ "7501;state=blocked:kind=permission:id=eu-west:title=%s:msg=%s"
+ % (b64("EU West"), b64("Approve deploy?"))
+ )
+ )
+ s.settle()
+ st = state(s)
+ check(
+ "the record waiting on a person wins the line",
+ st["program_state"] == "blocked" and "EU West" in st["status"],
+ str(st),
+ )
+ check(
+ "a record with no app of its own inherits one",
+ st["program_app"] == "deploy",
+ str(st),
+ )
+
+ s.raw(cmd("7501;state=clear:id=eu-west"))
+ s.settle()
+ check(
+ "clearing one record falls back to another, not to nothing",
+ state(s)["program_state"] == "working" and "US East" in state(s)["status"],
+ str(state(s)),
+ )
+
+ s.raw(cmd("7501;state=clear"))
+ s.settle()
+ st = state(s)
+ check(
+ "`clear` with no id removes every record",
+ not st["status"] and not st["busy"],
+ str(st),
+ )
+ check("...including the state", st["program_state"] == "", str(st))
+
+
+def test_clear_takes_the_children_with_it():
+ with Session(shell(), cols=70, rows=8) as s:
+ s.settle()
+ s.raw(cmd("7501;state=done:id=build:msg=" + b64("built")))
+ s.raw(cmd("7501;state=blocked:id=build/test:msg=" + b64("Run the slow suite?")))
+ s.settle()
+ check(
+ "the child is the one shown",
+ "slow suite" in state(s)["status"],
+ str(state(s)),
+ )
+
+ s.raw(cmd("7501;state=clear:id=build"))
+ s.settle()
+ st = state(s)
+ check(
+ "clearing a parent clears what is under it",
+ st["program_state"] == "" and not st["status"],
+ str(st),
+ )
+
+
+def test_records_die_with_the_prompt_and_the_program():
+ """There is no heartbeat in this protocol: a `working` record lives until
+ the terminal itself sees the work stop. A new shell prompt (OSC 133 A) and
+ the program exiting are both that moment; `done` survives both, because
+ being told what finished while you were away is the whole point."""
+ with Session(shell(), cols=70, rows=8) as s:
+ s.settle()
+ s.raw(cmd("7501;state=working:msg=" + b64("compiling")))
+ s.settle()
+ check(
+ "working, while it runs",
+ state(s)["program_state"] == "working",
+ str(state(s)),
+ )
+
+ s.raw(cmd("133;A"))
+ s.settle()
+ st = state(s)
+ check(
+ "a new prompt drops a `working` record", st["program_state"] == "", str(st)
+ )
+ check(
+ "...and the line it composed", not st["status"] and not st["busy"], str(st)
+ )
+
+ s.raw(cmd("7501;state=done:msg=" + b64("built 3 targets")))
+ s.raw(cmd("133;A"))
+ s.settle()
+ check(
+ "`done` outlives the prompt",
+ state(s)["program_state"] == "done" and "3 targets" in state(s)["status"],
+ str(state(s)),
+ )
+
+ # the parameters a real shell integration adds must not hide the A
+ s.raw(cmd("7501;state=working:msg=" + b64("and again")))
+ s.raw(cmd("133;A;aid=7;cl=m"))
+ s.settle()
+ check(
+ "a prompt mark with parameters still counts",
+ state(s)["program_state"] == "",
+ str(state(s)),
+ )
+
+ s.raw(cmd("7501;state=working:msg=" + b64("again")))
+ s.raw('printf "\\ec"' + "\\n") # RIS
+ s.settle()
+ check(
+ "a full reset removes every record",
+ state(s)["program_state"] == "",
+ str(state(s)),
+ )
+
+ keep = tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False)
+ keep.write('keep_dead "all"\n') # so there is still a pane to ask
+ keep.close()
+ body = 'printf "\\033]7501;state=working:msg=%s\\033\\\\"' % b64("halfway")
+ with Session(["/bin/sh", "-c", body], cols=70, rows=8, config=keep.name) as s:
+ s.until(lambda snap: s.panes() and not s.panes()[0]["alive"])
+ st = state(s)
+ check(
+ "a `working` record does not outlive its program",
+ st["program_state"] == "" and not st["status"],
+ str(st),
+ )
+
+ body = 'printf "\\033]7501;state=done:msg=%s\\033\\\\"' % b64("all done")
+ with Session(["/bin/sh", "-c", body], cols=70, rows=8, config=keep.name) as s:
+ s.until(lambda snap: s.panes() and not s.panes()[0]["alive"])
+ st = state(s)
+ check(
+ "a `done` record does, so the result is still there to find",
+ st["program_state"] == "done" and "all done" in st["status"],
+ str(st),
+ )
+ check("...and nothing about it is still happening", not st["busy"], str(st))
+
+
+def test_hostile_and_malformed_reports():
+ """A pane's output is untrusted input. The spec's rule is that a report
+ which breaks a limit is discarded *whole*, so none of this may leave a
+ record half-written over the one that was there."""
+ with Session(shell(), cols=70, rows=8) as s:
+ s.settle()
+ s.raw(cmd("7501;state=working:app=real:msg=" + b64("the real one")))
+ s.settle()
+ good = state(s)
+
+ cases = (
+ ("no state at all", "7501;msg=" + b64("nope")),
+ ("an unknown state", "7501;state=exploded:msg=" + b64("nope")),
+ ("base64 that is not base64", "7501;state=working:msg=not..base64"),
+ (
+ "a control character in the text",
+ "7501;state=working:msg=" + base64.b64encode(b"two\nlines").decode(),
+ ),
+ # "escape code safe UTF-8": well-formed, and no C0/DEL/C1 --
+ # checked on codepoints, so a C1 written as `C2 9B` is caught,
+ # and text that leaves the terminal as JSON cannot be malformed
+ (
+ "a C1 control, written in UTF-8",
+ "7501;state=working:msg="
+ + base64.b64encode("a\u009bb".encode()).decode(),
+ ),
+ (
+ "a bare 0x9b, which is not UTF-8 at all",
+ "7501;state=working:msg=" + base64.b64encode(b"a\x9bb").decode(),
+ ),
+ (
+ "a truncated UTF-8 sequence",
+ "7501;state=working:msg=" + base64.b64encode(b"caf\xc3").decode(),
+ ),
+ (
+ "an overlong encoding of /",
+ "7501;state=working:msg=" + base64.b64encode(b"a\xc0\xafb").decode(),
+ ),
+ (
+ "a lone surrogate",
+ "7501;state=working:msg="
+ + base64.b64encode(b"a\xed\xa0\x80b").decode(),
+ ),
+ (
+ "an escape sequence in the text",
+ "7501;state=working:msg="
+ + base64.b64encode(b"\x1b]5577;1;status;owned\x1b\\").decode(),
+ ),
+ ("a message over the limit", "7501;state=working:msg=" + b64("x" * 2100)),
+ ("a title over the limit", "7501;state=working:title=" + b64("x" * 300)),
+ ("an over-long key", "7501;state=working:keylongerthansixteen=1"),
+ ("an over-long app", "7501;state=working:app=" + "a" * 40),
+ ("an empty id segment", "7501;state=working:id=a//b"),
+ ("an id segment over 32", "7501;state=working:id=" + "a" * 40),
+ ("an id deeper than 8", "7501;state=working:id=" + "/".join("abcdefghi")),
+ )
+ for why, body in cases:
+ s.raw(cmd(body))
+ s.settle()
+ check(
+ "%s is discarded whole" % why,
+ state(s) == good,
+ "%s -> %s" % (why, state(s)),
+ )
+ # ...and put the good record back, so one case that *does* land
+ # cannot fail every case after it (which is how a 15-character
+ # "over-long" key once produced five failures)
+ s.raw(cmd("7501;state=working:app=real:msg=" + b64("the real one")))
+ s.settle()
+
+ # ...while a malformed *pair* is only skipped, and an unknown key is
+ # how the protocol is extended: both leave the rest of the report.
+ s.raw(cmd("7501;state=done:nonsense:=5:future=42:msg=" + b64("still read")))
+ s.settle()
+ st = state(s)
+ check(
+ "a malformed pair is skipped, the rest of the report is not",
+ st["program_state"] == "done" and st["status"] == "still read",
+ str(st),
+ )
+
+ s.raw(cmd("7501;state=working:progress=101:msg=" + b64("off the scale")))
+ s.settle()
+ check(
+ "a progress outside 0..100 is unknown, not clamped",
+ state(s)["progress"] == -1,
+ str(state(s)),
+ )
+
+ s.raw(cmd("7501;state=working:kind=sudo:msg=" + b64("hm")))
+ s.settle()
+ check(
+ "an unrecognised kind is absent",
+ state(s)["program_kind"] == "",
+ str(state(s)),
+ )
+
+ # 64 records is our cap, and a program that invents ids must not be
+ # able to push out the ones that matter by reporting past it.
+ for i in range(80):
+ s.raw(cmd("7501;state=idle:id=junk%d" % i))
+ s.raw(cmd("7501;state=blocked:id=real:msg=" + b64("still here")))
+ s.settle()
+ check(
+ "the newest record survives a flood of invented ones",
+ "still here" in state(s)["status"],
+ str(state(s)),
+ )
+
+
+def test_a_long_message_is_shortened_not_broken():
+ """A report may carry 2048 bytes of text; a pane status line has room for
+ 256 of them, and "terminals MAY shorten it". Shortening is allowed to lose
+ words and not allowed to lose half a character -- the cut lands on a UTF-8
+ boundary.
+
+ This is also where the only real bug in the first draft lived: the boundary
+ walk ran even when nothing was being cut, read one byte past the decoded
+ message, and took the last character off every status in the file."""
+ with Session(shell(), cols=70, rows=8) as s:
+ s.settle()
+
+ s.raw(cmd("7501;state=working:msg=" + b64("exactly this")))
+ s.settle()
+ check(
+ "a message that fits is not touched",
+ state(s)["status"] == "exactly this",
+ repr(state(s)["status"]),
+ )
+
+ s.raw(cmd("7501;state=working:msg=" + b64("caf\u00e9 \u2588 \U0001f600")))
+ s.settle()
+ check(
+ "multi-byte text survives the round trip",
+ state(s)["status"] == "caf\u00e9 \u2588 \U0001f600",
+ repr(state(s)["status"]),
+ )
+
+ # 400 three-byte characters: 1200 bytes, inside the protocol's limit
+ # and well outside ours, with no cut point that is also a byte
+ long = "\u2588" * 400
+ s.raw(cmd("7501;state=working:msg=" + b64(long)))
+ s.settle()
+ got = state(s)["status"]
+ check(
+ "an over-long message is kept, shortened",
+ got.startswith("\u2588\u2588"),
+ repr(got[:20]),
+ )
+ check(
+ "...to what the line holds", 0 < len(got) <= 256, "%d characters" % len(got)
+ )
+ check(
+ "...and not through the middle of a character",
+ set(got) == {"\u2588"},
+ repr(got[-8:]),
+ )
+
+
+def test_it_reaches_the_sidebar_like_any_other_status():
+ """The payoff: a pane reporting over 7501 is readable from another tab,
+ through the rows the sidebar already draws for OSC 5577. `working` gets
+ the spinner, `done` does not -- which is the distinction 5577's `busy` flag
+ was a thin proxy for."""
+ side = cfg('tab_bar_side "left"\ntab_bar_width 24\n')
+ with Session(shell(), cols=90, rows=20, config=side) as s:
+ s.settle(30)
+ s.raw(cmd("7501;state=working:msg=" + b64("make test")))
+ s.settle(30)
+ snap = s.snapshot()
+ row = next((y for y in range(20) if "make test" in snap.line(y)[:24]), None)
+ check(
+ "the status is a row under its tab",
+ row is not None,
+ repr(snap.line(2)[:24]),
+ )
+ if row is None:
+ return
+ check(
+ "a `working` record turns the spinner",
+ snap.line(row)[1] != " ",
+ repr(snap.line(row)[:24]),
+ )
+
+ s.raw(cmd("7501;state=done:msg=" + b64("make test")))
+ s.settle(30)
+ snap = s.snapshot()
+ check(
+ "`done` is the same words without it",
+ "make test" in snap.line(row)[:24] and snap.line(row)[1] == " ",
+ repr(snap.line(row)[:24]),
+ )
+
+
+def test_feature_detection_cannot_be_looped():
+ """`OSC 7501 ; ?` is answered with the bytes it was sent -- the one shape
+ OSC 5577 forbids, because a pane that echoes its input (`cat`, `tee`, a
+ shell, an ssh hop to another supporting terminal) turns our answer back
+ into a question. 5577 fixed that by naming replies `-reply`; this protocol
+ cannot, so the answer is rate-limited instead.
+
+ `emit()` runs `cat -v`, so this pane *is* that case.
+ """
+ with Session(emit("\\033]7501;?\\033\\\\"), cols=60, rows=12) as s:
+ s.settle()
+ time.sleep(0.8) # real time: a loop needs none of our help to run
+ out = s.snapshot().pane_text(s.pane())
+ n = out.replace("\n", "").replace(" ", "").count("]7501;?")
+ check(
+ "the query is answered",
+ n >= 2, # the pane's own question, and ours echoed back
+ "%d copies: %r" % (n, out[:200]),
+ )
+ check(
+ "and answering it does not start a loop",
+ n <= 6,
+ "%d copies: %r" % (n, out[:200]),
+ )
+ check(
+ "the pane is still usable afterwards",
+ len(out) < 600,
+ "%d chars of screen" % len(out),
+ )
+
+ # The terminator is the program's choice and the answer has to match it:
+ # 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. (libghostty-vt answers the same way upstream.)
+ with Session(emit("\\033]7501;?\\x07"), cols=60, rows=12) as s:
+ s.settle()
+ out = s.snapshot().pane_text(s.pane()).replace("\n", "").replace(" ", "")
+ check(
+ "a query ended with BEL is answered with BEL",
+ "]7501;?^G" in out,
+ repr(out[:120]),
+ )
+
+
+def test_the_two_protocols_share_one_slot():
+ """`status` and `busy` are what every reader of a pane asks for, so both
+ protocols compose into them, last writer wins. Taking the slot takes the
+ records too: a `program_state` describing text that is no longer on screen
+ would be worse than none."""
+ with Session(shell(), cols=70, rows=8) as s:
+ s.settle()
+ s.raw(cmd("7501;state=working:app=cargo:msg=" + b64("from 7501")))
+ s.settle()
+ check(
+ "7501 has the line", state(s)["program_state"] == "working", str(state(s))
+ )
+
+ s.raw(cmd("5577;1;status;from 5577"))
+ s.settle()
+ st = state(s)
+ check("5577 takes the line", st["status"] == "from 5577", str(st))
+ check(
+ "...and nothing stale is left describing it",
+ st["program_state"] == "" and st["program_app"] == "" and not st["busy"],
+ str(st),
+ )
+
+ s.raw(cmd("7501;state=done:msg=" + b64("back to 7501")))
+ s.settle()
+ st = state(s)
+ check(
+ "and 7501 can take it back",
+ st["status"] == "back to 7501" and st["program_state"] == "done",
+ str(st),
+ )
+
+ # a 5577 status before anything speaks 7501 is not cleared by 7501's
+ # own clearing rules: those records were never there
+ s.raw(cmd("5577;1;status;mine"))
+ s.raw(cmd("7501;state=clear"))
+ s.settle()
+ check(
+ "`clear` does not reach into the other protocol's slot",
+ state(s)["status"] == "mine",
+ str(state(s)),
+ )
+
+
+def test_a_report_split_across_reads():
+ with Session(shell(), cols=70, rows=8) as s:
+ s.settle()
+ s.raw(r'printf "\e]7501;state=work"' + "\\n")
+ s.settle(80)
+ s.raw(r'printf "ing:msg=aW4gcGllY2Vz"' + "\\n")
+ s.settle(80)
+ s.raw(r'printf "\x07"' + "\\n")
+ s.settle()
+ st = state(s)
+ check(
+ "a report arriving in pieces is still understood",
+ st["program_state"] == "working" and st["status"] == "in pieces",
+ str(st),
+ )
+
+
+if __name__ == "__main__":
+ test_a_report_becomes_the_pane_status()
+ test_the_five_states()
+ test_a_report_replaces_its_record_whole()
+ test_several_records()
+ test_clear_takes_the_children_with_it()
+ test_records_die_with_the_prompt_and_the_program()
+ test_hostile_and_malformed_reports()
+ test_a_long_message_is_shortened_not_broken()
+ test_it_reaches_the_sidebar_like_any_other_status()
+ test_feature_detection_cannot_be_looped()
+ test_the_two_protocols_share_one_slot()
+ test_a_report_split_across_reads()
+ sys.exit(report())