aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-20 23:23:07 +0200
committeruser <user@clank>2026-08-20 23:23:07 +0200
commit965966f9fb3808fbc69e6cb55960ad7a3c72c5f4 (patch)
tree388d17a48155b9fae25c65fdc6c8765b6bb3ec01
parentslosh: floating panes and the drop grammar (diff)
downloadslosh-965966f9fb3808fbc69e6cb55960ad7a3c72c5f4.tar.gz
slosh-965966f9fb3808fbc69e6cb55960ad7a3c72c5f4.tar.xz
slosh-965966f9fb3808fbc69e6cb55960ad7a3c72c5f4.zip
slosh: OSC 8 hyperlinks pass through the compositor
lib-vt tracks a hyperlink per cell and slosh dropped every one of them: the client's terminal can only offer a link it was sent, so `ls --hyperlink`, gcc diagnostics and friends silently lost theirs inside the session. The link rides beside the cell -- cell_t is the shader ABI and a link is not a colour -- as a plane of ids into an interned URI table, append-only so an id in prev_link still means the same URI next frame (which is what lets the diff compare ids instead of strings), and capped so a hostile pane cannot grow it forever. URIs with control bytes are refused outright: they are emitted verbatim inside an OSC, and a control byte in one is a way to write escape sequences into the client's terminal. The diff wraps emitted runs in OSC 8, closes what it opens before the frame ends, and treats a link-only change as a change. Chrome painted over a link takes the link with it, so a frame, modal or float across a URL is never clickable with the pane's link; links survive scrollback with the text that carries them. Plain-text URLs stay the outer terminal's own matcher, with the catch documented in panes.md: under mouse capture Ghostty only offers links with shift added to the gesture (shift+cmd hover/click), shift being its capture escape. Wrapped URLs can never be regex-matched through a multiplexer -- rows repaint as hard lines -- which is exactly what OSC 8 links, passing through whole, are for. `snapshot format:"bytes"` returns the emitter's own output for a frame (a second call is the delta): the model and the bytes are two different things, and this codebase already paid once for asserting on one while the other was wrong.
-rw-r--r--docs/scripting.md2
-rw-r--r--include/slosh.h16
-rw-r--r--src/pane.c40
-rw-r--r--src/screen.c97
-rw-r--r--tests/test_links.py118
5 files changed, 268 insertions, 5 deletions
diff --git a/docs/scripting.md b/docs/scripting.md
index 43615a2..0074782 100644
--- a/docs/scripting.md
+++ b/docs/scripting.md
@@ -21,7 +21,7 @@ Panes and tabs are addressed by **id**, so a background tab is scriptable.
| verb | |
|---|---|
| `panes` `tabs` | what exists, with ids, rects, titles, purposes, state. A pane's `tab_id` is what `move-pane` and `select-tab` want; its `tab` is where that tab sits in the strip |
-| `snapshot` | the composited screen, as JSON or `format:"text"` |
+| `snapshot` | the composited screen, as JSON, `format:"text"`, or `format:"bytes"` — the emitter's own output for this frame (a second call is the delta) |
| `deadline` | when the session wants its next frame, in ms, or -1 |
| `send` | bytes as if typed, decoded like input (`"data":"\\x01\\\\"`) |
| `raw` | bytes straight into the focused pane's pty |
diff --git a/include/slosh.h b/include/slosh.h
index 834879f..45392af 100644
--- a/include/slosh.h
+++ b/include/slosh.h
@@ -47,6 +47,16 @@ typedef struct {
uint16_t cols, rows;
cell_t *cur; /* what we want on screen */
cell_t *prev; /* what the terminal last received */
+ /* OSC 8 hyperlinks, carried beside the cells rather than in them: cell_t
+ * is the shader ABI and a link is not a colour. 0 is no link; anything
+ * else is 1+index into `links`. The table is append-only for the screen's
+ * lifetime, so an id in `prev_link` still means the same URI next frame —
+ * which is what lets the diff compare ids instead of strings — and it is
+ * capped, because a hostile pane must not be able to grow it forever: a
+ * link past the cap is dropped, which costs a click and nothing else. */
+ uint16_t *cur_link, *prev_link;
+ char **links;
+ size_t nlinks;
bool force_full;
hitlist_t hits;
@@ -77,6 +87,12 @@ uint16_t screen_cells(const char *txt);
* of characters when any of them is wide. */
uint16_t screen_text(screen_t *s, uint16_t x, uint16_t y, const char *txt,
color_t fg, color_t bg, uint16_t attrs);
+/* Intern a hyperlink URI into this screen's table: an id for screen_set_link,
+ * or 0 for none — an empty URI, one with control bytes in it, or a full
+ * table all answer 0, because a link that cannot be emitted safely is a link
+ * the frame does not carry. */
+uint16_t screen_link_id(screen_t *s, const char *uri, size_t len);
+void screen_set_link(screen_t *s, uint16_t x, uint16_t y, uint16_t id);
/* Diff cur against prev into s->out (the minimal byte stream for a terminal). */
void screen_render(screen_t *s);
/* screen_render, then write it to fd. */
diff --git a/src/pane.c b/src/pane.c
index 00b1e59..5cf0bab 100644
--- a/src/pane.c
+++ b/src/pane.c
@@ -1145,6 +1145,30 @@ static color_t to_color(const GhosttyColorRgb *c, bool ok) {
return out;
}
+/* The hyperlink on a cell, as this screen's interned id. The raw cell only
+ * says *whether* there is one; the URI takes a grid-ref lookup, so the flag
+ * gates it and a run of cells sharing one link costs one lookup — the last
+ * answer is remembered for exactly as long as the flag keeps being true with
+ * the same URI, which is what a link is. */
+static uint16_t cell_link_id(pane_t *p, screen_t *s, uint16_t x, uint16_t y,
+ char *last_uri, size_t *last_len,
+ uint16_t *last_id) {
+ GhosttyGridRef ref;
+ if (!grid_ref_at(p, x, y, &ref)) return 0;
+ uint8_t uri[1024];
+ size_t len = 0;
+ if (ghostty_grid_ref_hyperlink_uri(&ref, uri, sizeof uri, &len) !=
+ GHOSTTY_SUCCESS ||
+ !len)
+ return 0;
+ if (len == *last_len && memcmp(uri, last_uri, len) == 0) return *last_id;
+ uint16_t id = screen_link_id(s, (const char *)uri, len);
+ memcpy(last_uri, uri, len);
+ *last_len = len;
+ *last_id = id;
+ return id;
+}
+
void pane_compose(pane_t *p, screen_t *s, uint16_t x0, uint16_t y0,
bool focused) {
if (ghostty_render_state_update(p->rstate, p->term) != GHOSTTY_SUCCESS)
@@ -1155,6 +1179,10 @@ void pane_compose(pane_t *p, screen_t *s, uint16_t x0, uint16_t y0,
&p->rows) != GHOSTTY_SUCCESS)
return;
+ char last_uri[1024];
+ size_t last_len = 0;
+ uint16_t last_id = 0;
+
uint16_t y = 0;
while (ghostty_render_state_row_iterator_next(p->rows)) {
if (y >= p->rows_n) break;
@@ -1179,18 +1207,28 @@ void pane_compose(pane_t *p, screen_t *s, uint16_t x0, uint16_t y0,
GhosttyCell raw = {0};
GhosttyCellWide wide = GHOSTTY_CELL_WIDE_NARROW;
+ bool linked = false;
if (ghostty_render_state_row_cells_get(
p->cells, GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_RAW, &raw) ==
- GHOSTTY_SUCCESS)
+ GHOSTTY_SUCCESS) {
ghostty_cell_get(raw, GHOSTTY_CELL_DATA_WIDE, &wide);
+ ghostty_cell_get(raw, GHOSTTY_CELL_DATA_HAS_HYPERLINK, &linked);
+ }
if (wide == GHOSTTY_CELL_WIDE_SPACER_TAIL) {
dst->width = 0;
dst->len = 0;
+ screen_set_link(s, (uint16_t)(x0 + x), (uint16_t)(y0 + y), 0);
x++;
continue;
}
+ /* OSC 8 hyperlinks ride beside the cell, or they would be lost: the
+ * client's terminal can only offer a link it was sent. */
+ screen_set_link(
+ s, (uint16_t)(x0 + x), (uint16_t)(y0 + y),
+ linked ? cell_link_id(p, s, x, y, last_uri, &last_len, &last_id) : 0);
+
char utf8[16] = {0};
GhosttyBuffer gb = {.ptr = (uint8_t *)utf8, .cap = sizeof utf8, .len = 0};
if (ghostty_render_state_row_cells_get(
diff --git a/src/screen.c b/src/screen.c
index b3c8564..7bd474a 100644
--- a/src/screen.c
+++ b/src/screen.c
@@ -82,6 +82,8 @@ static cell_t blank_cell(void) {
return c;
}
+#define LINKS_MAX 4096 /* unique URIs a screen will carry; then they drop */
+
void screen_init(screen_t *s, uint16_t cols, uint16_t rows) {
memset(s, 0, sizeof *s);
s->cols = cols;
@@ -89,6 +91,8 @@ void screen_init(screen_t *s, uint16_t cols, uint16_t rows) {
size_t n = (size_t)cols * rows;
s->cur = calloc(n, sizeof(cell_t));
s->prev = calloc(n, sizeof(cell_t));
+ s->cur_link = calloc(n, sizeof(uint16_t));
+ s->prev_link = calloc(n, sizeof(uint16_t));
s->force_full = true;
screen_clear(s);
}
@@ -96,6 +100,10 @@ void screen_init(screen_t *s, uint16_t cols, uint16_t rows) {
void screen_free(screen_t *s) {
free(s->cur);
free(s->prev);
+ free(s->cur_link);
+ free(s->prev_link);
+ for (size_t i = 0; i < s->nlinks; i++) free(s->links[i]);
+ free(s->links);
free(s->out);
free(s->hits.items);
memset(s, 0, sizeof *s);
@@ -105,11 +113,17 @@ void screen_resize(screen_t *s, uint16_t cols, uint16_t rows) {
if (cols == s->cols && rows == s->rows) return;
free(s->cur);
free(s->prev);
+ free(s->cur_link);
+ free(s->prev_link);
s->cols = cols;
s->rows = rows;
size_t n = (size_t)cols * rows;
s->cur = calloc(n, sizeof(cell_t));
s->prev = calloc(n, sizeof(cell_t));
+ s->cur_link = calloc(n, sizeof(uint16_t));
+ s->prev_link = calloc(n, sizeof(uint16_t));
+ /* The URI table survives a resize on purpose: ids in flight stay
+ * meaningful, and a resize is not a reason to forget what a link was. */
s->force_full = true;
screen_clear(s);
}
@@ -118,6 +132,33 @@ void screen_clear(screen_t *s) {
size_t n = (size_t)s->cols * s->rows;
cell_t b = blank_cell();
for (size_t i = 0; i < n; i++) s->cur[i] = b;
+ memset(s->cur_link, 0, n * sizeof(uint16_t));
+}
+
+uint16_t screen_link_id(screen_t *s, const char *uri, size_t len) {
+ if (!uri || !len || len > 1024) return 0;
+ /* A URI is emitted verbatim inside an OSC, so a control byte in one is a
+ * way to write escape sequences into the client's terminal. lib-vt cannot
+ * hand us its own terminators, but the rest of C0 is cheap to refuse and
+ * free to never think about again. */
+ for (size_t i = 0; i < len; i++)
+ if ((unsigned char)uri[i] < 0x20 || uri[i] == 0x7f) return 0;
+ for (size_t i = 0; i < s->nlinks; i++)
+ if (strlen(s->links[i]) == len && memcmp(s->links[i], uri, len) == 0)
+ return (uint16_t)(i + 1);
+ if (s->nlinks >= LINKS_MAX) return 0;
+ char *copy = malloc(len + 1);
+ if (!copy) return 0;
+ memcpy(copy, uri, len);
+ copy[len] = 0;
+ s->links = realloc(s->links, (s->nlinks + 1) * sizeof *s->links);
+ s->links[s->nlinks++] = copy;
+ return (uint16_t)s->nlinks;
+}
+
+void screen_set_link(screen_t *s, uint16_t x, uint16_t y, uint16_t id) {
+ if (x >= s->cols || y >= s->rows) return;
+ s->cur_link[(size_t)y * s->cols + x] = id;
}
cell_t *screen_at(screen_t *s, uint16_t x, uint16_t y) {
@@ -228,6 +269,10 @@ void screen_put_utf8(screen_t *s, uint16_t x, uint16_t y, const char *txt,
memcpy(c->text, txt, len);
c->len = (uint8_t)len;
c->width = screen_width_of(txt, len);
+ /* Chrome over a link is chrome: writing a cell through here takes any
+ * hyperlink a pane left on it, or a frame drawn across a URL would be
+ * clickable. */
+ screen_set_link(s, x, y, 0);
/* The second half of a wide cell is a tail: never painted, and skipped by
* the diff. Without claiming it, the cell to the right keeps whatever was
* there and the terminal draws both. */
@@ -237,6 +282,7 @@ void screen_put_utf8(screen_t *s, uint16_t x, uint16_t y, const char *txt,
memset(tail, 0, sizeof *tail);
tail->fg = fg;
tail->bg = bg;
+ screen_set_link(s, (uint16_t)(x + 1), y, 0);
} else {
c->width = 1; /* no room for the tail: do not claim what is not there */
}
@@ -305,19 +351,30 @@ void screen_render(screen_t *s) {
if (s->force_full) {
out_str(s, "\x1b[?25l\x1b[0m\x1b[H\x1b[2J");
memset(s->prev, 0, (size_t)s->cols * s->rows * sizeof(cell_t));
+ memset(s->prev_link, 0, (size_t)s->cols * s->rows * sizeof(uint16_t));
painted = true;
}
bool have_pos = false, have_style = false;
uint16_t cx = 0, cy = 0;
cell_t style = {0};
+ /* Which hyperlink the output stream currently has open (OSC 8). Only the
+ * cells this frame emits pass through it — an unchanged cell keeps the
+ * link the terminal already holds for it — and it is closed before the
+ * frame ends, so link state never leaks into the cursor restore or the
+ * next frame's first cell. */
+ uint16_t open_link = 0;
for (uint16_t y = 0; y < s->rows; y++) {
for (uint16_t x = 0; x < s->cols; x++) {
- cell_t *c = &s->cur[(size_t)y * s->cols + x];
- cell_t *p = &s->prev[(size_t)y * s->cols + x];
+ size_t idx = (size_t)y * s->cols + x;
+ cell_t *c = &s->cur[idx];
+ cell_t *p = &s->prev[idx];
if (c->width == 0) continue; /* tail of a wide cell: never painted */
- if (cell_eq(c, p)) continue;
+ /* A cell whose only change is its link is still a change: the glyph
+ * has to be re-sent inside (or outside) the OSC 8 wrapper for the
+ * terminal to move it between links. */
+ if (cell_eq(c, p) && s->cur_link[idx] == s->prev_link[idx]) continue;
if (!painted) {
out_str(s, "\x1b[?25l");
@@ -334,16 +391,27 @@ void screen_render(screen_t *s) {
style = *c;
have_style = true;
}
+ if (s->cur_link[idx] != open_link) {
+ if (open_link) out_str(s, "\x1b]8;;\x1b\\");
+ open_link = s->cur_link[idx];
+ if (open_link) {
+ out_str(s, "\x1b]8;;");
+ out_str(s, s->links[open_link - 1]);
+ out_str(s, "\x1b\\");
+ }
+ }
if (c->len)
out_bytes(s, c->text, c->len);
else
out_str(s, " ");
*p = *c;
+ s->prev_link[idx] = s->cur_link[idx];
cx += c->width ? c->width : 1;
if (cx >= s->cols) have_pos = false; /* wrap is terminal-dependent */
}
}
+ if (open_link) out_str(s, "\x1b]8;;\x1b\\");
bool cursor_moved = s->cursor_visible != s->shown_cursor_visible ||
s->cursor_x != s->shown_cursor_x ||
@@ -459,6 +527,29 @@ char *screen_dump_json(screen_t *s) {
dump_style_runs(s, &j);
+ /* Hyperlinks as runs, like the styles: a test wants "these six cells are
+ * this URI", not one object per cell. */
+ json_arr_open(&j, "links");
+ for (uint16_t y = 0; y < s->rows; y++) {
+ uint16_t x = 0;
+ while (x < s->cols) {
+ uint16_t id = s->cur_link[(size_t)y * s->cols + x];
+ if (!id) {
+ x++;
+ continue;
+ }
+ uint16_t x0 = x;
+ while (x < s->cols && s->cur_link[(size_t)y * s->cols + x] == id) x++;
+ json_obj_open(&j, NULL);
+ json_int(&j, "x", x0);
+ json_int(&j, "y", y);
+ json_int(&j, "w", (uint16_t)(x - x0));
+ json_str(&j, "uri", s->links[id - 1], strlen(s->links[id - 1]));
+ json_obj_close(&j);
+ }
+ }
+ json_arr_close(&j);
+
json_obj_open(&j, "cursor");
json_bool(&j, "visible", s->cursor_visible);
json_int(&j, "x", s->cursor_x);
diff --git a/tests/test_links.py b/tests/test_links.py
new file mode 100644
index 0000000..1c08f72
--- /dev/null
+++ b/tests/test_links.py
@@ -0,0 +1,118 @@
+#!/usr/bin/env python3
+"""OSC 8 hyperlinks pass through the compositor.
+
+lib-vt tracks a hyperlink per cell; without re-emission the client's terminal
+is never told, so `ls --hyperlink` and friends silently lose their links
+inside the session. The link rides beside the cell (cell_t is the shader ABI
+and a link is not a colour), the diff wraps emitted runs in OSC 8, and chrome
+painted over a link takes the link with it.
+
+Plain-text URLs are deliberately not slosh's job: the outer terminal's own
+matcher runs over what slosh paints. (Under Ghostty, add shift to the usual
+gesture while a mouse-owning app runs -- shift is its mouse-capture escape.)
+"""
+
+import sys
+
+from harness import Session, check, report
+
+URI = "https://sl0p.foo/x"
+SH = [
+ "/bin/sh",
+ "-c",
+ 'printf "\\033]8;;' + URI + '\\033\\\\click me\\033]8;;\\033\\\\ done\\n";'
+ " stty raw -echo; cat",
+]
+
+
+def links(s):
+ return s.snapshot().data["links"]
+
+
+def test_a_hyperlink_lands_on_the_cells_that_carry_it():
+ with Session(SH, cols=80, rows=20) as s:
+ s.until_text("click me")
+ ls = links(s)
+ check("one link run", len(ls) == 1, str(ls))
+ if not ls:
+ return
+ run = ls[0]
+ check("with the URI intact", run["uri"] == URI, str(run))
+ check(
+ "covering exactly the linked text",
+ run["w"] == len("click me"),
+ str(run),
+ )
+ snap = s.snapshot()
+ check(
+ "on the cells that show it",
+ snap.line(run["y"])[run["x"] : run["x"] + run["w"]] == "click me",
+ repr(snap.line(run["y"])),
+ )
+
+
+def test_the_emitted_bytes_wrap_the_run_in_osc8():
+ """The model and the bytes are two different things, and this session
+ already paid once for asserting on one while the other was wrong."""
+ with Session(SH, cols=80, rows=20) as s:
+ s.until_text("click me")
+ b = s.api("snapshot", format="bytes")["bytes"]
+ check(
+ "the frame opens the link before its text",
+ f"\x1b]8;;{URI}\x1b\\" in b,
+ repr(b[-400:]),
+ )
+ opens = b.count("\x1b]8;;h") # an open names a scheme
+ closes = b.count("\x1b]8;;\x1b\\")
+ check("and closes what it opens", opens == closes == 1, f"{opens}/{closes}")
+
+ delta = s.api("snapshot", format="bytes")["bytes"]
+ check("an unchanged frame emits nothing", delta == "", repr(delta[:80]))
+
+
+def test_chrome_over_a_link_is_chrome():
+ """Anything painted over a link takes the link with it: a modal across a
+ URL must not leave its own box clickable with the pane's link."""
+ with Session(SH, cols=80, rows=24) as s:
+ s.until_text("click me")
+ run = links(s)[0]
+ s.send(r"\x01?") # the cheatsheet: an opaque box over the layout
+ s.settle(40)
+ snap = s.snapshot()
+ covered = snap.line(run["y"])[run["x"] : run["x"] + run["w"]] != "click me"
+ check("the modal covers the linked text", covered, repr(snap.line(run["y"])))
+ check(
+ "and carries no link over it",
+ snap.data["links"] == [],
+ str(snap.data["links"]),
+ )
+ s.send("q")
+ s.settle(40)
+ check("dismissed, the link is back", len(links(s)) == 1, str(links(s)))
+
+
+def test_a_link_scrolled_away_offers_nothing():
+ """Only what is on screen can be clicked: the viewport is the promise,
+ and scrollback brings the link back with the text that carries it."""
+ with Session(SH, cols=80, rows=12) as s:
+ s.until_text("click me")
+ check("the link is there", len(links(s)) == 1, str(links(s)))
+ # Push it out of the viewport: cat echoes what raw feeds it.
+ s.raw(r"\n" * 30)
+ s.until(lambda snap: "click me" not in snap.screen())
+ check("scrolled away, no link", links(s) == [], str(links(s)))
+ s.send(r"\x01\e[1~") # C-a Home: to the oldest line
+ snap = s.until_text("click me")
+ check(
+ "and back with the text, in scrollback",
+ len(snap.data["links"]) == 1,
+ str(snap.data["links"]),
+ )
+
+
+if __name__ == "__main__":
+ test_a_hyperlink_lands_on_the_cells_that_carry_it()
+ test_the_emitted_bytes_wrap_the_run_in_osc8()
+ test_chrome_over_a_link_is_chrome()
+ test_a_link_scrolled_away_offers_nothing()
+ sys.exit(report())