Skip to content

Commit 9c94f68

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 9c94f68

File tree

5 files changed

+192
-60
lines changed

5 files changed

+192
-60
lines changed

lib/ghostty.ts

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -605,9 +605,51 @@ 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+
// Try with initial buffer, retry with larger if needed (for very long URLs)
621+
const bufferSizes = [2048, 8192, 32768];
622+
623+
for (const bufSize of bufferSizes) {
624+
const bufPtr = this.exports.ghostty_wasm_alloc_u8_array(bufSize);
625+
626+
try {
627+
const bytesWritten = this.exports.ghostty_terminal_get_hyperlink_uri(
628+
this.handle,
629+
row,
630+
col,
631+
bufPtr,
632+
bufSize
633+
);
634+
635+
// 0 means no hyperlink at this position
636+
if (bytesWritten === 0) return null;
637+
638+
// -1 means buffer too small, try next size
639+
if (bytesWritten === -1) continue;
640+
641+
// Negative values other than -1 are errors
642+
if (bytesWritten < 0) return null;
643+
644+
const bytes = new Uint8Array(this.memory.buffer, bufPtr, bytesWritten);
645+
return new TextDecoder().decode(bytes.slice());
646+
} finally {
647+
this.exports.ghostty_wasm_free_u8_array(bufPtr, bufSize);
648+
}
649+
}
650+
651+
// URI too long even for largest buffer
652+
return null;
611653
}
612654

613655
/**

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: 43 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,58 @@ 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+
// TODO: Add scrollback hyperlink support. This would require a new WASM API
62+
// (ghostty_terminal_get_scrollback_hyperlink_uri) to look up URIs in history.
63+
// For now, OSC 8 links in scrollback won't be clickable.
64+
if (viewportRow < 0) continue;
5565

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

6068
if (uri) {
69+
// Find the end of this link by scanning forward until we hit a cell
70+
// without a hyperlink or with a different URI
71+
let endX = x;
72+
for (let col = x + 1; col < line.length; col++) {
73+
const nextCell = line.getCell(col);
74+
if (!nextCell || nextCell.getHyperlinkId() === 0) break;
75+
76+
// Check if this cell has the same URI
77+
const nextUri = this.terminal.wasmTerm!.getHyperlinkUri(viewportRow, col);
78+
if (nextUri !== uri) break;
79+
80+
endX = col;
81+
}
82+
83+
// Mark all columns in this link as visited
84+
for (let col = x; col <= endX; col++) {
85+
visitedPositions.add(col);
86+
}
87+
88+
const range: IBufferRange = {
89+
start: { x, y },
90+
end: { x: endX, y },
91+
};
92+
6193
links.push({
6294
text: uri,
6395
range,
@@ -211,6 +243,7 @@ export interface ITerminalForOSC8Provider {
211243
};
212244
};
213245
wasmTerm?: {
214-
getHyperlinkUri(id: number): string | null;
246+
getHyperlinkUri(row: number, col: number): string | null;
247+
getScrollbackLength(): number;
215248
};
216249
}

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

0 commit comments

Comments
 (0)