Skip to content

Commit 30ecf0f

Browse files
slang25claude
andcommitted
feat: enable OSC 8 hyperlink clicking with Cmd/Ctrl modifier
Add support for clicking OSC 8 hyperlinks in the terminal. This involves: 1. Add ghostty_terminal_get_hyperlink_uri() to the WASM API to retrieve the actual URI for cells marked with hyperlinks. The hyperlink_id field is just a boolean indicator; the real URI is stored in Ghostty's internal hyperlink set and must be looked up via this new function. 2. Update OSC8LinkProvider to use the new WASM API, with proper coordinate conversion from buffer rows to viewport rows (accounting for scrollback). 3. Fix LinkDetector to cache links by position range rather than hyperlink_id, since all hyperlinks incorrectly shared the same ID value (1), causing multiple links on one line to all open the same URL. Now Cmd+clicking (Mac) or Ctrl+clicking (Windows/Linux) an OSC 8 hyperlink correctly opens that specific link's URI. Co-Authored-By: Claude Haiku 4.5 <[email protected]>
1 parent 03ead6e commit 30ecf0f

File tree

5 files changed

+175
-60
lines changed

5 files changed

+175
-60
lines changed

lib/ghostty.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -605,9 +605,37 @@ export class GhosttyTerminal {
605605
return this.exports.ghostty_terminal_is_row_wrapped(this.handle, row) !== 0;
606606
}
607607

608-
/** Hyperlink URI not yet exposed in simplified API */
609-
getHyperlinkUri(_id: number): string | null {
610-
return null; // TODO: Add hyperlink support
608+
/**
609+
* Get the hyperlink URI for a cell at the given position.
610+
* @param row Row index (0-based, in active viewport)
611+
* @param col Column index (0-based)
612+
* @returns The URI string, or null if no hyperlink at that position
613+
*/
614+
getHyperlinkUri(row: number, col: number): string | null {
615+
// Check if WASM has this function (requires rebuilt WASM with hyperlink support)
616+
if (!this.exports.ghostty_terminal_get_hyperlink_uri) {
617+
return null;
618+
}
619+
620+
const bufSize = 2048; // URLs can be long
621+
const bufPtr = this.exports.ghostty_wasm_alloc_u8_array(bufSize);
622+
623+
try {
624+
const bytesWritten = this.exports.ghostty_terminal_get_hyperlink_uri(
625+
this.handle,
626+
row,
627+
col,
628+
bufPtr,
629+
bufSize
630+
);
631+
632+
if (bytesWritten <= 0) return null;
633+
634+
const bytes = new Uint8Array(this.memory.buffer, bufPtr, bytesWritten);
635+
return new TextDecoder().decode(bytes.slice());
636+
} finally {
637+
this.exports.ghostty_wasm_free_u8_array(bufPtr, bufSize);
638+
}
611639
}
612640

613641
/**

lib/link-detector.ts

Lines changed: 11 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@ export class LinkDetector {
4040
* @returns Link at position, or undefined if none
4141
*/
4242
async getLinkAt(col: number, row: number): Promise<ILink | undefined> {
43-
// First, check if this cell has a hyperlink_id (fast path for OSC 8)
4443
const line = this.terminal.buffer.active.getLine(row);
4544
if (!line || col < 0 || col >= line.length) {
4645
return undefined;
@@ -50,13 +49,11 @@ export class LinkDetector {
5049
if (!cell) {
5150
return undefined;
5251
}
53-
const hyperlinkId = cell.getHyperlinkId();
5452

55-
if (hyperlinkId > 0) {
56-
// Fast path: check cache by hyperlink_id
57-
const cacheKey = `h${hyperlinkId}`;
58-
if (this.linkCache.has(cacheKey)) {
59-
return this.linkCache.get(cacheKey);
53+
// Check if any cached link contains this position (fast path)
54+
for (const link of this.linkCache.values()) {
55+
if (this.isPositionInLink(col, row, link)) {
56+
return link;
6057
}
6158
}
6259

@@ -65,14 +62,7 @@ export class LinkDetector {
6562
await this.scanRow(row);
6663
}
6764

68-
// Check cache again (hyperlinkId or position-based)
69-
if (hyperlinkId > 0) {
70-
const cacheKey = `h${hyperlinkId}`;
71-
const link = this.linkCache.get(cacheKey);
72-
if (link) return link;
73-
}
74-
75-
// Check if any cached link contains this position
65+
// Check cache again after scanning
7666
for (const link of this.linkCache.values()) {
7767
if (this.isPositionInLink(col, row, link)) {
7868
return link;
@@ -109,31 +99,14 @@ export class LinkDetector {
10999

110100
/**
111101
* Cache a link for fast lookup
102+
*
103+
* Note: We cache by position range, not hyperlink_id, because the WASM
104+
* returns hyperlink_id as a boolean (0 or 1), not a unique identifier.
105+
* The actual unique identifier is the URI which is retrieved separately.
112106
*/
113107
private cacheLink(link: ILink): void {
114-
// Try to get hyperlink_id for this link
115-
const { start } = link.range;
116-
const line = this.terminal.buffer.active.getLine(start.y);
117-
if (line) {
118-
const cell = line.getCell(start.x);
119-
if (!cell) {
120-
// Fallback: cache by position range
121-
const { start: s, end: e } = link.range;
122-
const cacheKey = `r${s.y}:${s.x}-${e.x}`;
123-
this.linkCache.set(cacheKey, link);
124-
return;
125-
}
126-
const hyperlinkId = cell.getHyperlinkId();
127-
128-
if (hyperlinkId > 0) {
129-
// Cache by hyperlink_id (best case - stable across rows)
130-
this.linkCache.set(`h${hyperlinkId}`, link);
131-
return;
132-
}
133-
}
134-
135-
// Fallback: cache by position range
136-
// Format: r${row}:${startX}-${endX}
108+
// Cache by position range - this uniquely identifies links even when
109+
// multiple OSC 8 links exist on the same line
137110
const { start: s, end: e } = link.range;
138111
const cacheKey = `r${s.y}:${s.x}-${e.x}`;
139112
this.linkCache.set(cacheKey, link);

lib/providers/osc8-link-provider.ts

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export class OSC8LinkProvider implements ILinkProvider {
2828
*/
2929
provideLinks(y: number, callback: (links: ILink[] | undefined) => void): void {
3030
const links: ILink[] = [];
31-
const visitedIds = new Set<number>();
31+
const visitedPositions = new Set<number>(); // Track which columns we've already processed
3232

3333
const line = this.terminal.buffer.active.getLine(y);
3434
if (!line) {
@@ -38,26 +38,55 @@ export class OSC8LinkProvider implements ILinkProvider {
3838

3939
// Scan through this line looking for hyperlink_id
4040
for (let x = 0; x < line.length; x++) {
41+
// Skip already processed positions
42+
if (visitedPositions.has(x)) continue;
43+
4144
const cell = line.getCell(x);
4245
if (!cell) continue;
4346

4447
const hyperlinkId = cell.getHyperlinkId();
4548

46-
// Skip cells without links or already processed links
47-
if (hyperlinkId === 0 || visitedIds.has(hyperlinkId)) {
49+
// Skip cells without links
50+
if (hyperlinkId === 0) {
4851
continue;
4952
}
5053

51-
visitedIds.add(hyperlinkId);
54+
// Get the URI from WASM using viewport row and column
55+
// The y parameter is a buffer row, but WASM expects a viewport row
56+
if (!this.terminal.wasmTerm) continue;
57+
const scrollbackLength = this.terminal.wasmTerm.getScrollbackLength();
58+
const viewportRow = y - scrollbackLength;
5259

53-
// Find the full extent of this link (may span multiple lines)
54-
const range = this.findLinkRange(hyperlinkId, y, x);
60+
// Skip if this row is in scrollback (not in active viewport)
61+
if (viewportRow < 0) continue;
5562

56-
// Get the URI from WASM
57-
if (!this.terminal.wasmTerm) continue;
58-
const uri = this.terminal.wasmTerm.getHyperlinkUri(hyperlinkId);
63+
const uri = this.terminal.wasmTerm.getHyperlinkUri(viewportRow, x);
5964

6065
if (uri) {
66+
// Find the end of this link by scanning forward until we hit a cell
67+
// without a hyperlink or with a different URI
68+
let endX = x;
69+
for (let col = x + 1; col < line.length; col++) {
70+
const nextCell = line.getCell(col);
71+
if (!nextCell || nextCell.getHyperlinkId() === 0) break;
72+
73+
// Check if this cell has the same URI
74+
const nextUri = this.terminal.wasmTerm!.getHyperlinkUri(viewportRow, col);
75+
if (nextUri !== uri) break;
76+
77+
endX = col;
78+
}
79+
80+
// Mark all columns in this link as visited
81+
for (let col = x; col <= endX; col++) {
82+
visitedPositions.add(col);
83+
}
84+
85+
const range: IBufferRange = {
86+
start: { x, y },
87+
end: { x: endX, y },
88+
};
89+
6190
links.push({
6291
text: uri,
6392
range,
@@ -211,6 +240,7 @@ export interface ITerminalForOSC8Provider {
211240
};
212241
};
213242
wasmTerm?: {
214-
getHyperlinkUri(id: number): string | null;
243+
getHyperlinkUri(row: number, col: number): string | null;
244+
getScrollbackLength(): number;
215245
};
216246
}

lib/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,15 @@ export interface GhosttyWasmExports extends WebAssembly.Exports {
460460
): number; // Returns codepoint count or -1 on error
461461
ghostty_terminal_is_row_wrapped(terminal: TerminalHandle, row: number): number;
462462

463+
// Hyperlink API
464+
ghostty_terminal_get_hyperlink_uri(
465+
terminal: TerminalHandle,
466+
row: number,
467+
col: number,
468+
bufPtr: number,
469+
bufLen: number
470+
): number; // Returns bytes written, 0 if no hyperlink, -1 on error
471+
463472
// Response API (for DSR and other terminal queries)
464473
ghostty_terminal_has_response(terminal: TerminalHandle): boolean;
465474
ghostty_terminal_read_response(terminal: TerminalHandle, bufPtr: number, bufLen: number): number; // Returns bytes written, 0 if no response, -1 on error

patches/ghostty-wasm-api.patch

Lines changed: 84 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,10 @@ index 4f8fef88e..ca9fb1d4d 100644
2929
#include <ghostty/vt/key.h>
3030
diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h
3131
new file mode 100644
32-
index 000000000..298ad36c1
32+
index 000000000..2c9dd99c7
3333
--- /dev/null
3434
+++ b/include/ghostty/vt/terminal.h
35-
@@ -0,0 +1,249 @@
35+
@@ -0,0 +1,269 @@
3636
+/**
3737
+ * @file terminal.h
3838
+ *
@@ -256,6 +256,26 @@ index 000000000..298ad36c1
256256
+bool ghostty_terminal_is_row_wrapped(GhosttyTerminal term, int y);
257257
+
258258
+/* ============================================================================
259+
+ * Hyperlink API
260+
+ * ========================================================================= */
261+
+
262+
+/**
263+
+ * Get the hyperlink URI for a cell in the active viewport.
264+
+ * @param row Row index (0-based)
265+
+ * @param col Column index (0-based)
266+
+ * @param out_buffer Buffer to receive URI bytes (UTF-8)
267+
+ * @param buffer_size Size of buffer in bytes
268+
+ * @return Number of bytes written, 0 if no hyperlink, -1 on error
269+
+ */
270+
+int ghostty_terminal_get_hyperlink_uri(
271+
+ GhosttyTerminal term,
272+
+ int row,
273+
+ int col,
274+
+ uint8_t* out_buffer,
275+
+ size_t buffer_size
276+
+);
277+
+
278+
+/* ============================================================================
259279
+ * Response API - for DSR and other terminal queries
260280
+ * ========================================================================= */
261281
+
@@ -283,10 +303,10 @@ index 000000000..298ad36c1
283303
+
284304
+#endif /* GHOSTTY_VT_TERMINAL_H */
285305
diff --git a/src/lib_vt.zig b/src/lib_vt.zig
286-
index 03a883e20..f07bbd759 100644
306+
index 03a883e20..32d5f7c38 100644
287307
--- a/src/lib_vt.zig
288308
+++ b/src/lib_vt.zig
289-
@@ -140,6 +140,41 @@ comptime {
309+
@@ -140,6 +140,44 @@ comptime {
290310
@export(&c.sgr_unknown_partial, .{ .name = "ghostty_sgr_unknown_partial" });
291311
@export(&c.sgr_attribute_tag, .{ .name = "ghostty_sgr_attribute_tag" });
292312
@export(&c.sgr_attribute_value, .{ .name = "ghostty_sgr_attribute_value" });
@@ -322,14 +342,17 @@ index 03a883e20..f07bbd759 100644
322342
+ @export(&c.terminal_get_scrollback_grapheme, .{ .name = "ghostty_terminal_get_scrollback_grapheme" });
323343
+ @export(&c.terminal_is_row_wrapped, .{ .name = "ghostty_terminal_is_row_wrapped" });
324344
+
345+
+ // Hyperlink API
346+
+ @export(&c.terminal_get_hyperlink_uri, .{ .name = "ghostty_terminal_get_hyperlink_uri" });
347+
+
325348
+ // Response API (for DSR and other queries)
326349
+ @export(&c.terminal_has_response, .{ .name = "ghostty_terminal_has_response" });
327350
+ @export(&c.terminal_read_response, .{ .name = "ghostty_terminal_read_response" });
328351

329352
// On Wasm we need to export our allocator convenience functions.
330353
if (builtin.target.cpu.arch.isWasm()) {
331354
diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig
332-
index bc92597f5..18503933f 100644
355+
index bc92597f5..e352c150a 100644
333356
--- a/src/terminal/c/main.zig
334357
+++ b/src/terminal/c/main.zig
335358
@@ -4,6 +4,7 @@ pub const key_event = @import("key_event.zig");
@@ -340,7 +363,7 @@ index bc92597f5..18503933f 100644
340363

341364
// The full C API, unexported.
342365
pub const osc_new = osc.new;
343-
@@ -52,6 +53,42 @@ pub const key_encoder_encode = key_encode.encode;
366+
@@ -52,6 +53,45 @@ pub const key_encoder_encode = key_encode.encode;
344367

345368
pub const paste_is_safe = paste.is_safe;
346369

@@ -376,14 +399,17 @@ index bc92597f5..18503933f 100644
376399
+pub const terminal_get_scrollback_grapheme = terminal.getScrollbackGrapheme;
377400
+pub const terminal_is_row_wrapped = terminal.isRowWrapped;
378401
+
402+
+// Hyperlink API
403+
+pub const terminal_get_hyperlink_uri = terminal.getHyperlinkUri;
404+
+
379405
+// Response API (for DSR and other queries)
380406
+pub const terminal_has_response = terminal.hasResponse;
381407
+pub const terminal_read_response = terminal.readResponse;
382408
+
383409
test {
384410
_ = color;
385411
_ = osc;
386-
@@ -59,6 +96,7 @@ test {
412+
@@ -59,6 +99,7 @@ test {
387413
_ = key_encode;
388414
_ = paste;
389415
_ = sgr;
@@ -393,10 +419,10 @@ index bc92597f5..18503933f 100644
393419
_ = @import("../../lib/allocator.zig");
394420
diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig
395421
new file mode 100644
396-
index 000000000..d57b4e405
422+
index 000000000..2eca7a93a
397423
--- /dev/null
398424
+++ b/src/terminal/c/terminal.zig
399-
@@ -0,0 +1,1025 @@
425+
@@ -0,0 +1,1074 @@
400426
+//! C API wrapper for Terminal
401427
+//!
402428
+//! This provides a minimal, high-performance interface to Ghostty's Terminal
@@ -1357,6 +1383,55 @@ index 000000000..d57b4e405
13571383
+}
13581384
+
13591385
+// ============================================================================
1386+
+// Hyperlink API
1387+
+// ============================================================================
1388+
+
1389+
+/// Get the hyperlink URI for a cell in the active viewport.
1390+
+/// Returns number of bytes written, 0 if no hyperlink, -1 on error.
1391+
+pub fn getHyperlinkUri(
1392+
+ ptr: ?*anyopaque,
1393+
+ row: c_int,
1394+
+ col: c_int,
1395+
+ out: [*]u8,
1396+
+ buf_size: usize,
1397+
+) callconv(.c) c_int {
1398+
+ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return -1));
1399+
+ const t = &wrapper.terminal;
1400+
+
1401+
+ if (row < 0 or col < 0) return -1;
1402+
+
1403+
+ // Get the pin for this row from the terminal's active screen
1404+
+ const pages = &t.screens.active.pages;
1405+
+ const pin = pages.pin(.{ .active = .{ .y = @intCast(row) } }) orelse return -1;
1406+
+
1407+
+ const cells = pin.cells(.all);
1408+
+ const page = pin.node.data;
1409+
+ const x: usize = @intCast(col);
1410+
+
1411+
+ if (x >= cells.len) return -1;
1412+
+
1413+
+ const cell = &cells[x];
1414+
+
1415+
+ // Check if cell has a hyperlink
1416+
+ if (!cell.hyperlink) return 0;
1417+
+
1418+
+ // Look up the hyperlink ID from the page
1419+
+ const hyperlink_id = page.lookupHyperlink(cell) orelse return 0;
1420+
+
1421+
+ // Get the hyperlink entry from the set
1422+
+ const hyperlink_entry = page.hyperlink_set.get(page.memory, hyperlink_id);
1423+
+
1424+
+ // Get the URI bytes from the page memory
1425+
+ const uri = hyperlink_entry.uri.slice(page.memory);
1426+
+
1427+
+ if (uri.len == 0) return 0;
1428+
+ if (buf_size < uri.len) return -1;
1429+
+
1430+
+ @memcpy(out[0..uri.len], uri);
1431+
+ return @intCast(uri.len);
1432+
+}
1433+
+
1434+
+// ============================================================================
13601435
+// Response API - for DSR and other terminal queries
13611436
+// ============================================================================
13621437
+

0 commit comments

Comments
 (0)