Skip to content

Commit 3525675

Browse files
slang25claude
andauthored
feat: enable OSC 8 hyperlink clicking with Cmd/Ctrl modifier (#117)
* 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> * fix: address PR review feedback for OSC 8 hyperlinks - Add scrollback support so OSC 8 links remain clickable after scrolling - Retry with larger buffers (2KB/8KB/32KB) for long URIs - Preserve OSC8 link precedence over regex when ranges overlap Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
1 parent fd09412 commit 3525675

5 files changed

Lines changed: 328 additions & 66 deletions

File tree

lib/ghostty.ts

Lines changed: 92 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -605,9 +605,98 @@ 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;
653+
}
654+
655+
/**
656+
* Get the hyperlink URI for a cell in the scrollback buffer.
657+
* @param offset Scrollback line offset (0 = oldest, scrollback_len-1 = newest)
658+
* @param col Column index (0-based)
659+
* @returns The URI string, or null if no hyperlink at that position
660+
*/
661+
getScrollbackHyperlinkUri(offset: number, col: number): string | null {
662+
// Check if WASM has this function
663+
if (!this.exports.ghostty_terminal_get_scrollback_hyperlink_uri) {
664+
return null;
665+
}
666+
667+
// Try with initial buffer, retry with larger if needed (for very long URLs)
668+
const bufferSizes = [2048, 8192, 32768];
669+
670+
for (const bufSize of bufferSizes) {
671+
const bufPtr = this.exports.ghostty_wasm_alloc_u8_array(bufSize);
672+
673+
try {
674+
const bytesWritten = this.exports.ghostty_terminal_get_scrollback_hyperlink_uri(
675+
this.handle,
676+
offset,
677+
col,
678+
bufPtr,
679+
bufSize
680+
);
681+
682+
// 0 means no hyperlink at this position
683+
if (bytesWritten === 0) return null;
684+
685+
// -1 means buffer too small, try next size
686+
if (bytesWritten === -1) continue;
687+
688+
// Negative values other than -1 are errors
689+
if (bytesWritten < 0) return null;
690+
691+
const bytes = new Uint8Array(this.memory.buffer, bufPtr, bytesWritten);
692+
return new TextDecoder().decode(bytes.slice());
693+
} finally {
694+
this.exports.ghostty_wasm_free_u8_array(bufPtr, bufSize);
695+
}
696+
}
697+
698+
// URI too long even for largest buffer
699+
return null;
611700
}
612701

613702
/**

lib/link-detector.ts

Lines changed: 19 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
* Link detection and caching system
33
*
44
* The LinkDetector coordinates between multiple link providers and caches
5-
* results for performance. It uses hyperlink_id for intelligent caching
6-
* since the same hyperlink_id always represents the same link.
5+
* results for performance. Links are cached by position range, and earlier
6+
* providers (e.g. OSC8) take precedence over later ones (e.g. regex).
77
*/
88

99
import type { IBufferCellPosition, ILink, ILinkProvider } from './types';
@@ -14,9 +14,7 @@ import type { IBufferCellPosition, ILink, ILinkProvider } from './types';
1414
export class LinkDetector {
1515
private providers: ILinkProvider[] = [];
1616

17-
// Cache links by hyperlink_id for fast lookups
18-
// Key format: `h${hyperlinkId}` for OSC 8 links
19-
// Key format: `r${row}:${startX}-${endX}` for regex links (future)
17+
// Cache links by position range: `r${row}:${startX}-${endX}`
2018
private linkCache = new Map<string, ILink>();
2119

2220
// Track which rows have been scanned to avoid redundant provider calls
@@ -40,7 +38,6 @@ export class LinkDetector {
4038
* @returns Link at position, or undefined if none
4139
*/
4240
async getLinkAt(col: number, row: number): Promise<ILink | undefined> {
43-
// First, check if this cell has a hyperlink_id (fast path for OSC 8)
4441
const line = this.terminal.buffer.active.getLine(row);
4542
if (!line || col < 0 || col >= line.length) {
4643
return undefined;
@@ -50,13 +47,11 @@ export class LinkDetector {
5047
if (!cell) {
5148
return undefined;
5249
}
53-
const hyperlinkId = cell.getHyperlinkId();
5450

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);
51+
// Check if any cached link contains this position (fast path)
52+
for (const link of this.linkCache.values()) {
53+
if (this.isPositionInLink(col, row, link)) {
54+
return link;
6055
}
6156
}
6257

@@ -65,14 +60,7 @@ export class LinkDetector {
6560
await this.scanRow(row);
6661
}
6762

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
63+
// Check cache again after scanning
7664
for (const link of this.linkCache.values()) {
7765
if (this.isPositionInLink(col, row, link)) {
7866
return link;
@@ -109,34 +97,21 @@ export class LinkDetector {
10997

11098
/**
11199
* Cache a link for fast lookup
100+
*
101+
* Note: We cache by position range, not hyperlink_id, because the WASM
102+
* returns hyperlink_id as a boolean (0 or 1), not a unique identifier.
103+
* The actual unique identifier is the URI which is retrieved separately.
112104
*/
113105
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}
106+
// Cache by position range - this uniquely identifies links even when
107+
// multiple OSC 8 links exist on the same line
137108
const { start: s, end: e } = link.range;
138109
const cacheKey = `r${s.y}:${s.x}-${e.x}`;
139-
this.linkCache.set(cacheKey, link);
110+
// Don't overwrite existing entries - earlier providers (OSC8) take
111+
// precedence over later ones (regex) for the same range
112+
if (!this.linkCache.has(cacheKey)) {
113+
this.linkCache.set(cacheKey, link);
114+
}
140115
}
141116

142117
/**

lib/providers/osc8-link-provider.ts

Lines changed: 50 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,64 @@ 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);
52-
53-
// Find the full extent of this link (may span multiple lines)
54-
const range = this.findLinkRange(hyperlinkId, y, x);
55-
5654
// Get the URI from WASM
55+
// The y parameter is a buffer row - we need to determine if it's in
56+
// scrollback or the active viewport and use the appropriate API
5757
if (!this.terminal.wasmTerm) continue;
58-
const uri = this.terminal.wasmTerm.getHyperlinkUri(hyperlinkId);
58+
const scrollbackLength = this.terminal.wasmTerm.getScrollbackLength();
59+
const viewportRow = y - scrollbackLength;
60+
61+
let uri: string | null;
62+
if (viewportRow < 0) {
63+
// Row is in scrollback - use scrollback API
64+
// y is the buffer row (0 = oldest), scrollback offset is also 0 = oldest
65+
uri = this.terminal.wasmTerm.getScrollbackHyperlinkUri(y, x);
66+
} else {
67+
// Row is in active viewport
68+
uri = this.terminal.wasmTerm.getHyperlinkUri(viewportRow, x);
69+
}
5970

6071
if (uri) {
72+
// Find the end of this link by scanning forward until we hit a cell
73+
// without a hyperlink or with a different URI
74+
let endX = x;
75+
for (let col = x + 1; col < line.length; col++) {
76+
const nextCell = line.getCell(col);
77+
if (!nextCell || nextCell.getHyperlinkId() === 0) break;
78+
79+
// Check if this cell has the same URI (use appropriate API for scrollback vs viewport)
80+
const nextUri =
81+
viewportRow < 0
82+
? this.terminal.wasmTerm!.getScrollbackHyperlinkUri(y, col)
83+
: this.terminal.wasmTerm!.getHyperlinkUri(viewportRow, col);
84+
if (nextUri !== uri) break;
85+
86+
endX = col;
87+
}
88+
89+
// Mark all columns in this link as visited
90+
for (let col = x; col <= endX; col++) {
91+
visitedPositions.add(col);
92+
}
93+
94+
const range: IBufferRange = {
95+
start: { x, y },
96+
end: { x: endX, y },
97+
};
98+
6199
links.push({
62100
text: uri,
63101
range,
@@ -211,6 +249,8 @@ export interface ITerminalForOSC8Provider {
211249
};
212250
};
213251
wasmTerm?: {
214-
getHyperlinkUri(id: number): string | null;
252+
getHyperlinkUri(row: number, col: number): string | null;
253+
getScrollbackHyperlinkUri(offset: number, col: number): string | null;
254+
getScrollbackLength(): number;
215255
};
216256
}

lib/types.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,22 @@ 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+
ghostty_terminal_get_scrollback_hyperlink_uri(
472+
terminal: TerminalHandle,
473+
offset: number,
474+
col: number,
475+
bufPtr: number,
476+
bufLen: number
477+
): number; // Returns bytes written, 0 if no hyperlink, -1 on error
478+
463479
// Response API (for DSR and other terminal queries)
464480
ghostty_terminal_has_response(terminal: TerminalHandle): boolean;
465481
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)