aboutsummaryrefslogtreecommitdiffstats
path: root/src
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 /src
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.
Diffstat (limited to 'src')
-rw-r--r--src/pane.c40
-rw-r--r--src/screen.c97
2 files changed, 133 insertions, 4 deletions
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);