Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 45 additions & 3 deletions lib/ghostty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
49 changes: 11 additions & 38 deletions lib/link-detector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ export class LinkDetector {
* @returns Link at position, or undefined if none
*/
async getLinkAt(col: number, row: number): Promise<ILink | undefined> {
// 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;
Expand All @@ -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;
}
}

Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
50 changes: 40 additions & 10 deletions lib/providers/osc8-link-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>();
const visitedPositions = new Set<number>(); // Track which columns we've already processed

const line = this.terminal.buffer.active.getLine(y);
if (!line) {
Expand All @@ -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;
Comment on lines +57 to +61

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Don’t skip OSC8 links that are in scrollback

When the terminal is scrolled up, rows mapped into the scrollback region are explicitly skipped, so OSC 8 links in visible scrollback lines will never be returned to the LinkDetector and Cmd/Ctrl‑click won’t open them. This is user‑visible when you scroll back to click a link in output history. If scrollback should be interactive like regex URLs, the provider needs a scrollback‑capable URI lookup (or a fallback) instead of early‑returning.

Useful? React with 👍 / 👎.


// 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,
Expand Down Expand Up @@ -211,6 +240,7 @@ export interface ITerminalForOSC8Provider {
};
};
wasmTerm?: {
getHyperlinkUri(id: number): string | null;
getHyperlinkUri(row: number, col: number): string | null;
getScrollbackLength(): number;
};
}
9 changes: 9 additions & 0 deletions lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading