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 +// ============================================================================ +