From e593548680d683c703878c2b52d5907a835c0471 Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Sun, 11 Jan 2026 12:15:32 +0000 Subject: [PATCH] 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 --- lib/ghostty.ts | 48 ++++++++++++++- lib/link-detector.ts | 49 ++++----------- lib/providers/osc8-link-provider.ts | 50 ++++++++++++---- lib/types.ts | 9 +++ patches/ghostty-wasm-api.patch | 93 ++++++++++++++++++++++++++--- 5 files changed, 189 insertions(+), 60 deletions(-) diff --git a/lib/ghostty.ts b/lib/ghostty.ts index 7449185..6ccf88f 100644 --- a/lib/ghostty.ts +++ b/lib/ghostty.ts @@ -605,9 +605,51 @@ export class GhosttyTerminal { return this.exports.ghostty_terminal_is_row_wrapped(this.handle, row) !== 0; } - /** Hyperlink URI not yet exposed in simplified API */ - getHyperlinkUri(_id: number): string | null { - return null; // TODO: Add hyperlink support + /** + * Get the hyperlink URI for a cell at the given position. + * @param row Row index (0-based, in active viewport) + * @param col Column index (0-based) + * @returns The URI string, or null if no hyperlink at that position + */ + getHyperlinkUri(row: number, col: number): string | null { + // Check if WASM has this function (requires rebuilt WASM with hyperlink support) + if (!this.exports.ghostty_terminal_get_hyperlink_uri) { + return null; + } + + // Try with initial buffer, retry with larger if needed (for very long URLs) + const bufferSizes = [2048, 8192, 32768]; + + for (const bufSize of bufferSizes) { + const bufPtr = this.exports.ghostty_wasm_alloc_u8_array(bufSize); + + try { + const bytesWritten = this.exports.ghostty_terminal_get_hyperlink_uri( + this.handle, + row, + col, + bufPtr, + bufSize + ); + + // 0 means no hyperlink at this position + if (bytesWritten === 0) return null; + + // -1 means buffer too small, try next size + if (bytesWritten === -1) continue; + + // Negative values other than -1 are errors + if (bytesWritten < 0) return null; + + const bytes = new Uint8Array(this.memory.buffer, bufPtr, bytesWritten); + return new TextDecoder().decode(bytes.slice()); + } finally { + this.exports.ghostty_wasm_free_u8_array(bufPtr, bufSize); + } + } + + // URI too long even for largest buffer + return null; } /** diff --git a/lib/link-detector.ts b/lib/link-detector.ts index 408f9c3..e5cf958 100644 --- a/lib/link-detector.ts +++ b/lib/link-detector.ts @@ -40,7 +40,6 @@ export class LinkDetector { * @returns Link at position, or undefined if none */ async getLinkAt(col: number, row: number): Promise { - // First, check if this cell has a hyperlink_id (fast path for OSC 8) const line = this.terminal.buffer.active.getLine(row); if (!line || col < 0 || col >= line.length) { return undefined; @@ -50,13 +49,11 @@ export class LinkDetector { if (!cell) { return undefined; } - const hyperlinkId = cell.getHyperlinkId(); - if (hyperlinkId > 0) { - // Fast path: check cache by hyperlink_id - const cacheKey = `h${hyperlinkId}`; - if (this.linkCache.has(cacheKey)) { - return this.linkCache.get(cacheKey); + // Check if any cached link contains this position (fast path) + for (const link of this.linkCache.values()) { + if (this.isPositionInLink(col, row, link)) { + return link; } } @@ -65,14 +62,7 @@ export class LinkDetector { await this.scanRow(row); } - // Check cache again (hyperlinkId or position-based) - if (hyperlinkId > 0) { - const cacheKey = `h${hyperlinkId}`; - const link = this.linkCache.get(cacheKey); - if (link) return link; - } - - // Check if any cached link contains this position + // Check cache again after scanning for (const link of this.linkCache.values()) { if (this.isPositionInLink(col, row, link)) { return link; @@ -109,31 +99,14 @@ export class LinkDetector { /** * Cache a link for fast lookup + * + * Note: We cache by position range, not hyperlink_id, because the WASM + * returns hyperlink_id as a boolean (0 or 1), not a unique identifier. + * The actual unique identifier is the URI which is retrieved separately. */ private cacheLink(link: ILink): void { - // Try to get hyperlink_id for this link - const { start } = link.range; - const line = this.terminal.buffer.active.getLine(start.y); - if (line) { - const cell = line.getCell(start.x); - if (!cell) { - // Fallback: cache by position range - const { start: s, end: e } = link.range; - const cacheKey = `r${s.y}:${s.x}-${e.x}`; - this.linkCache.set(cacheKey, link); - return; - } - const hyperlinkId = cell.getHyperlinkId(); - - if (hyperlinkId > 0) { - // Cache by hyperlink_id (best case - stable across rows) - this.linkCache.set(`h${hyperlinkId}`, link); - return; - } - } - - // Fallback: cache by position range - // Format: r${row}:${startX}-${endX} + // Cache by position range - this uniquely identifies links even when + // multiple OSC 8 links exist on the same line const { start: s, end: e } = link.range; const cacheKey = `r${s.y}:${s.x}-${e.x}`; this.linkCache.set(cacheKey, link); diff --git a/lib/providers/osc8-link-provider.ts b/lib/providers/osc8-link-provider.ts index b487500..9a3cf82 100644 --- a/lib/providers/osc8-link-provider.ts +++ b/lib/providers/osc8-link-provider.ts @@ -28,7 +28,7 @@ export class OSC8LinkProvider implements ILinkProvider { */ provideLinks(y: number, callback: (links: ILink[] | undefined) => void): void { const links: ILink[] = []; - const visitedIds = new Set(); + const visitedPositions = new Set(); // Track which columns we've already processed const line = this.terminal.buffer.active.getLine(y); if (!line) { @@ -38,26 +38,55 @@ export class OSC8LinkProvider implements ILinkProvider { // Scan through this line looking for hyperlink_id for (let x = 0; x < line.length; x++) { + // Skip already processed positions + if (visitedPositions.has(x)) continue; + const cell = line.getCell(x); if (!cell) continue; const hyperlinkId = cell.getHyperlinkId(); - // Skip cells without links or already processed links - if (hyperlinkId === 0 || visitedIds.has(hyperlinkId)) { + // Skip cells without links + if (hyperlinkId === 0) { continue; } - visitedIds.add(hyperlinkId); + // Get the URI from WASM using viewport row and column + // The y parameter is a buffer row, but WASM expects a viewport row + if (!this.terminal.wasmTerm) continue; + const scrollbackLength = this.terminal.wasmTerm.getScrollbackLength(); + const viewportRow = y - scrollbackLength; - // Find the full extent of this link (may span multiple lines) - const range = this.findLinkRange(hyperlinkId, y, x); + // Skip if this row is in scrollback (not in active viewport) + if (viewportRow < 0) continue; - // Get the URI from WASM - if (!this.terminal.wasmTerm) continue; - const uri = this.terminal.wasmTerm.getHyperlinkUri(hyperlinkId); + const uri = this.terminal.wasmTerm.getHyperlinkUri(viewportRow, x); if (uri) { + // Find the end of this link by scanning forward until we hit a cell + // without a hyperlink or with a different URI + let endX = x; + for (let col = x + 1; col < line.length; col++) { + const nextCell = line.getCell(col); + if (!nextCell || nextCell.getHyperlinkId() === 0) break; + + // Check if this cell has the same URI + const nextUri = this.terminal.wasmTerm!.getHyperlinkUri(viewportRow, col); + if (nextUri !== uri) break; + + endX = col; + } + + // Mark all columns in this link as visited + for (let col = x; col <= endX; col++) { + visitedPositions.add(col); + } + + const range: IBufferRange = { + start: { x, y }, + end: { x: endX, y }, + }; + links.push({ text: uri, range, @@ -211,6 +240,7 @@ export interface ITerminalForOSC8Provider { }; }; wasmTerm?: { - getHyperlinkUri(id: number): string | null; + getHyperlinkUri(row: number, col: number): string | null; + getScrollbackLength(): number; }; } diff --git a/lib/types.ts b/lib/types.ts index e9182f2..053a0b9 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -460,6 +460,15 @@ export interface GhosttyWasmExports extends WebAssembly.Exports { ): number; // Returns codepoint count or -1 on error ghostty_terminal_is_row_wrapped(terminal: TerminalHandle, row: number): number; + // Hyperlink API + ghostty_terminal_get_hyperlink_uri( + terminal: TerminalHandle, + row: number, + col: number, + bufPtr: number, + bufLen: number + ): number; // Returns bytes written, 0 if no hyperlink, -1 on error + // Response API (for DSR and other terminal queries) ghostty_terminal_has_response(terminal: TerminalHandle): boolean; ghostty_terminal_read_response(terminal: TerminalHandle, bufPtr: number, bufLen: number): number; // Returns bytes written, 0 if no response, -1 on error diff --git a/patches/ghostty-wasm-api.patch b/patches/ghostty-wasm-api.patch index cb649c0..0db375c 100644 --- a/patches/ghostty-wasm-api.patch +++ b/patches/ghostty-wasm-api.patch @@ -29,10 +29,10 @@ index 4f8fef88e..ca9fb1d4d 100644 #include diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h new file mode 100644 -index 000000000..298ad36c1 +index 000000000..2c9dd99c7 --- /dev/null +++ b/include/ghostty/vt/terminal.h -@@ -0,0 +1,249 @@ +@@ -0,0 +1,269 @@ +/** + * @file terminal.h + * @@ -256,6 +256,26 @@ index 000000000..298ad36c1 +bool ghostty_terminal_is_row_wrapped(GhosttyTerminal term, int y); + +/* ============================================================================ ++ * Hyperlink API ++ * ========================================================================= */ ++ ++/** ++ * Get the hyperlink URI for a cell in the active viewport. ++ * @param row Row index (0-based) ++ * @param col Column index (0-based) ++ * @param out_buffer Buffer to receive URI bytes (UTF-8) ++ * @param buffer_size Size of buffer in bytes ++ * @return Number of bytes written, 0 if no hyperlink, -1 on error ++ */ ++int ghostty_terminal_get_hyperlink_uri( ++ GhosttyTerminal term, ++ int row, ++ int col, ++ uint8_t* out_buffer, ++ size_t buffer_size ++); ++ ++/* ============================================================================ + * Response API - for DSR and other terminal queries + * ========================================================================= */ + @@ -283,10 +303,10 @@ index 000000000..298ad36c1 + +#endif /* GHOSTTY_VT_TERMINAL_H */ diff --git a/src/lib_vt.zig b/src/lib_vt.zig -index 03a883e20..f07bbd759 100644 +index 03a883e20..32d5f7c38 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig -@@ -140,6 +140,41 @@ comptime { +@@ -140,6 +140,44 @@ comptime { @export(&c.sgr_unknown_partial, .{ .name = "ghostty_sgr_unknown_partial" }); @export(&c.sgr_attribute_tag, .{ .name = "ghostty_sgr_attribute_tag" }); @export(&c.sgr_attribute_value, .{ .name = "ghostty_sgr_attribute_value" }); @@ -322,6 +342,9 @@ index 03a883e20..f07bbd759 100644 + @export(&c.terminal_get_scrollback_grapheme, .{ .name = "ghostty_terminal_get_scrollback_grapheme" }); + @export(&c.terminal_is_row_wrapped, .{ .name = "ghostty_terminal_is_row_wrapped" }); + ++ // Hyperlink API ++ @export(&c.terminal_get_hyperlink_uri, .{ .name = "ghostty_terminal_get_hyperlink_uri" }); ++ + // Response API (for DSR and other queries) + @export(&c.terminal_has_response, .{ .name = "ghostty_terminal_has_response" }); + @export(&c.terminal_read_response, .{ .name = "ghostty_terminal_read_response" }); @@ -329,7 +352,7 @@ index 03a883e20..f07bbd759 100644 // On Wasm we need to export our allocator convenience functions. if (builtin.target.cpu.arch.isWasm()) { diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig -index bc92597f5..18503933f 100644 +index bc92597f5..e352c150a 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -4,6 +4,7 @@ pub const key_event = @import("key_event.zig"); @@ -340,7 +363,7 @@ index bc92597f5..18503933f 100644 // The full C API, unexported. pub const osc_new = osc.new; -@@ -52,6 +53,42 @@ pub const key_encoder_encode = key_encode.encode; +@@ -52,6 +53,45 @@ pub const key_encoder_encode = key_encode.encode; pub const paste_is_safe = paste.is_safe; @@ -376,6 +399,9 @@ index bc92597f5..18503933f 100644 +pub const terminal_get_scrollback_grapheme = terminal.getScrollbackGrapheme; +pub const terminal_is_row_wrapped = terminal.isRowWrapped; + ++// Hyperlink API ++pub const terminal_get_hyperlink_uri = terminal.getHyperlinkUri; ++ +// Response API (for DSR and other queries) +pub const terminal_has_response = terminal.hasResponse; +pub const terminal_read_response = terminal.readResponse; @@ -383,7 +409,7 @@ index bc92597f5..18503933f 100644 test { _ = color; _ = osc; -@@ -59,6 +96,7 @@ test { +@@ -59,6 +99,7 @@ test { _ = key_encode; _ = paste; _ = sgr; @@ -393,10 +419,10 @@ index bc92597f5..18503933f 100644 _ = @import("../../lib/allocator.zig"); diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig new file mode 100644 -index 000000000..d57b4e405 +index 000000000..2eca7a93a --- /dev/null +++ b/src/terminal/c/terminal.zig -@@ -0,0 +1,1025 @@ +@@ -0,0 +1,1074 @@ +//! C API wrapper for Terminal +//! +//! This provides a minimal, high-performance interface to Ghostty's Terminal @@ -1357,6 +1383,55 @@ index 000000000..d57b4e405 +} + +// ============================================================================ ++// Hyperlink API ++// ============================================================================ ++ ++/// Get the hyperlink URI for a cell in the active viewport. ++/// Returns number of bytes written, 0 if no hyperlink, -1 on error. ++pub fn getHyperlinkUri( ++ ptr: ?*anyopaque, ++ row: c_int, ++ col: c_int, ++ out: [*]u8, ++ buf_size: usize, ++) callconv(.c) c_int { ++ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return -1)); ++ const t = &wrapper.terminal; ++ ++ if (row < 0 or col < 0) return -1; ++ ++ // Get the pin for this row from the terminal's active screen ++ const pages = &t.screens.active.pages; ++ const pin = pages.pin(.{ .active = .{ .y = @intCast(row) } }) orelse return -1; ++ ++ const cells = pin.cells(.all); ++ const page = pin.node.data; ++ const x: usize = @intCast(col); ++ ++ if (x >= cells.len) return -1; ++ ++ const cell = &cells[x]; ++ ++ // Check if cell has a hyperlink ++ if (!cell.hyperlink) return 0; ++ ++ // Look up the hyperlink ID from the page ++ const hyperlink_id = page.lookupHyperlink(cell) orelse return 0; ++ ++ // Get the hyperlink entry from the set ++ const hyperlink_entry = page.hyperlink_set.get(page.memory, hyperlink_id); ++ ++ // Get the URI bytes from the page memory ++ const uri = hyperlink_entry.uri.slice(page.memory); ++ ++ if (uri.len == 0) return 0; ++ if (buf_size < uri.len) return -1; ++ ++ @memcpy(out[0..uri.len], uri); ++ return @intCast(uri.len); ++} ++ ++// ============================================================================ +// Response API - for DSR and other terminal queries +// ============================================================================ +