Skip to content

Commit 05bc928

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 <noreply@anthropic.com>
1 parent 03ead6e commit 05bc928

5 files changed

Lines changed: 168 additions & 53 deletions

File tree

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: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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
+
@@ -322,10 +342,13 @@ 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" });
328-
351+
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
@@ -376,6 +399,9 @@ 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;
@@ -1351,12 +1377,61 @@ index 000000000..d57b4e405
13511377
+ // Get pin for this row in active area
13521378
+ const pin = pages.pin(.{ .active = .{ .y = @intCast(y) } }) orelse return false;
13531379
+ const rac = pin.rowAndCell();
1354-
+
1380+
+
13551381
+ // wrap_continuation means this row continues from the previous row
13561382
+ return rac.row.wrap_continuation;
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)