Skip to content

Commit b84b8d5

Browse files
authored
feat: add support for hover+clickable urls (#42)
1 parent 17a4957 commit b84b8d5

File tree

9 files changed

+569
-4
lines changed

9 files changed

+569
-4
lines changed

README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,44 @@ ws.onmessage = (event) => {
143143
};
144144
```
145145

146+
### URL Detection
147+
148+
Ghostty-web automatically detects and makes clickable:
149+
150+
- **OSC 8 hyperlinks** - Explicit terminal escape sequences (e.g., from `ls --hyperlink`)
151+
- **Plain text URLs** - Common protocols detected via regex (https, http, mailto, ssh, git, ftp, tel, magnet)
152+
153+
URLs are detected on hover and can be opened with Ctrl/Cmd+Click.
154+
155+
```typescript
156+
// URL detection works automatically after opening terminal
157+
await term.open(container);
158+
159+
// URLs in output become clickable automatically
160+
term.write('Visit https://github.com for code\r\n');
161+
term.write('Contact mailto:support@example.com\r\n');
162+
```
163+
164+
**Custom Link Providers**
165+
166+
Register custom providers to detect additional link types:
167+
168+
```typescript
169+
import { UrlRegexProvider } from '@coder/ghostty-web';
170+
171+
// Create custom provider
172+
const myProvider = {
173+
provideLinks(y, callback) {
174+
// Your detection logic here
175+
const links = detectCustomLinks(y);
176+
callback(links);
177+
},
178+
};
179+
180+
// Register after opening terminal
181+
term.registerLinkProvider(myProvider);
182+
```
183+
146184
See [AGENTS.md](AGENTS.md) for development guide and code patterns.
147185

148186
## Why This Approach?

bun.lock

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"devDependencies": {
77
"@biomejs/biome": "^1.9.4",
88
"@types/bun": "^1.3.2",
9+
"happy-dom": "^20.0.10",
910
"prettier": "^3.6.2",
1011
"typescript": "^5.9.3",
1112
"vite": "^4.5.0",
@@ -116,10 +117,12 @@
116117

117118
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
118119

119-
"@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="],
120+
"@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="],
120121

121122
"@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
122123

124+
"@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
125+
123126
"@volar/language-core": ["@volar/language-core@2.4.23", "", { "dependencies": { "@volar/source-map": "2.4.23" } }, "sha512-hEEd5ET/oSmBC6pi1j6NaNYRWoAiDhINbT8rmwtINugR39loROSlufGdYMF9TaKGfz+ViGs1Idi3mAhnuPcoGQ=="],
124127

125128
"@volar/source-map": ["@volar/source-map@2.4.23", "", {}, "sha512-Z1Uc8IB57Lm6k7q6KIDu/p+JWtf3xsXJqAX/5r18hYOTpJyBn0KXUR8oTJ4WFYOcDzWC9n3IflGgHowx6U6z9Q=="],
@@ -184,6 +187,8 @@
184187

185188
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
186189

190+
"happy-dom": ["happy-dom@20.0.10", "", { "dependencies": { "@types/node": "^20.0.0", "@types/whatwg-mimetype": "^3.0.2", "whatwg-mimetype": "^3.0.0" } }, "sha512-6umCCHcjQrhP5oXhrHQQvLB0bwb1UzHAHdsXy+FjtKoYjUhmNZsQL8NivwM1vDvNEChJabVrUYxUnp/ZdYmy2g=="],
191+
187192
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
188193

189194
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
@@ -266,7 +271,7 @@
266271

267272
"ufo": ["ufo@1.6.1", "", {}, "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA=="],
268273

269-
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
274+
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
270275

271276
"universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
272277

@@ -278,6 +283,8 @@
278283

279284
"vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="],
280285

286+
"whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
287+
281288
"yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
282289

283290
"@microsoft/api-extractor/typescript": ["typescript@5.8.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ=="],
@@ -288,8 +295,12 @@
288295

289296
"ajv-formats/ajv": ["ajv@8.13.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js": "^4.4.1" } }, "sha512-PRA911Blj99jR5RMeTunVbNXMF6Lp4vZXnk5GQjcnUWUTsrXtekg/pnmFFI2u/I36Y/2bITGS30GZCXei6uNkA=="],
290297

298+
"bun-types/@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="],
299+
291300
"mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],
292301

302+
"bun-types/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
303+
293304
"mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="],
294305
}
295306
}

demo/index.html

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,18 @@ <h3>🔍 Buffer Access API (NEW!)</h3>
337337
</div>
338338
</div>
339339

340+
<div class="feature-panel">
341+
<h3>🔗 URL Detection (NEW!)</h3>
342+
<p style="margin: 10px 0; font-size: 0.9em; opacity: 0.8">
343+
Plain text URLs are automatically detected and made clickable. Hover to see cursor change,
344+
Ctrl/Cmd+Click to open.
345+
</p>
346+
<div class="button-grid">
347+
<button class="test-button" id="btn-testUrls">📝 Write Test URLs</button>
348+
<button class="test-button" id="btn-testUrlEdgeCases">⚠️ Test URL Edge Cases</button>
349+
</div>
350+
</div>
351+
340352
<div class="warning">
341353
<strong>⚠️ Warning: Full Filesystem Access</strong>
342354
This demo has unrestricted access to your entire filesystem. It's meant for local
@@ -903,6 +915,66 @@ <h3>🔍 Buffer Access API (NEW!)</h3>
903915
logBufferEvent(` Row ${i}: ${wrapped ? '↪ wrapped' : '↓ new line'}`, true);
904916
}
905917
});
918+
919+
// =======================================================================
920+
// URL Detection Test Buttons
921+
// =======================================================================
922+
923+
document.getElementById('btn-testUrls').addEventListener('click', () => {
924+
term.write('\r\n');
925+
term.write(
926+
'═══════════════════════════════════════════════════════════════════════════════\r\n'
927+
);
928+
term.write('🔗 URL Detection Test\r\n');
929+
term.write(
930+
'═══════════════════════════════════════════════════════════════════════════════\r\n'
931+
);
932+
term.write('\r\n');
933+
term.write(
934+
'Hover over URLs to see cursor change, Ctrl/Cmd+Click to open in new tab:\r\n'
935+
);
936+
term.write('\r\n');
937+
term.write(
938+
'1. HTTPS: Visit https://github.com/coder/ghostty-web for the source code\r\n'
939+
);
940+
term.write('2. HTTP: Check out http://example.com for more information\r\n');
941+
term.write('3. Email: Contact mailto:support@example.com for help\r\n');
942+
term.write('4. SSH: Connect via ssh://user@server.com:22/path\r\n');
943+
term.write('5. Git: Clone git://github.com/user/repo.git to get started\r\n');
944+
term.write('6. FTP: Download from ftp://files.example.com/archive.zip\r\n');
945+
term.write('7. Tel: Call tel:+1-555-123-4567 for support\r\n');
946+
term.write('8. Multiple URLs: https://a.com and https://b.com on same line\r\n');
947+
term.write('\r\n');
948+
term.write('Try clicking the URLs above!\r\n');
949+
term.write('\r\n');
950+
});
951+
952+
document.getElementById('btn-testUrlEdgeCases').addEventListener('click', () => {
953+
term.write('\r\n');
954+
term.write(
955+
'═══════════════════════════════════════════════════════════════════════════════\r\n'
956+
);
957+
term.write('⚠️ URL Edge Cases\r\n');
958+
term.write(
959+
'═══════════════════════════════════════════════════════════════════════════════\r\n'
960+
);
961+
term.write('\r\n');
962+
term.write('URLs should be detected correctly in these cases:\r\n');
963+
term.write('\r\n');
964+
term.write('1. With period: Check https://example.com. More text here.\r\n');
965+
term.write('2. With comma: See https://example.com, then continue.\r\n');
966+
term.write('3. With parentheses: (visit https://example.com) for details\r\n');
967+
term.write('4. With exclamation: Visit https://example.com! Right now!\r\n');
968+
term.write('5. With query: https://example.com?foo=bar&baz=qux\r\n');
969+
term.write('6. With fragment: https://example.com/page#section-one\r\n');
970+
term.write('7. With port: https://example.com:8080/api/endpoint\r\n');
971+
term.write('\r\n');
972+
term.write('These should NOT be detected (not URLs):\r\n');
973+
term.write('8. File path: /home/user/file.txt\r\n');
974+
term.write('9. Relative path: ./relative/path/to/file\r\n');
975+
term.write('\r\n');
976+
});
977+
906978
// Expose terminal to console for debugging
907979
window.term = term;
908980
}

lib/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,9 @@ export type { SelectionCoordinates } from './selection-manager';
4343
// Addons
4444
export { FitAddon } from './addons/fit';
4545
export type { ITerminalDimensions } from './addons/fit';
46+
47+
// Link providers
48+
export { OSC8LinkProvider } from './providers/osc8-link-provider';
49+
export { UrlRegexProvider } from './providers/url-regex-provider';
50+
export { LinkDetector } from './link-detector';
51+
export type { ILink, ILinkProvider, IBufferCellPosition } from './types';
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
/**
2+
* URL Regex Link Provider
3+
*
4+
* Detects plain text URLs using regex pattern matching.
5+
* Supports common protocols but excludes file paths.
6+
*
7+
* This provider runs after OSC8LinkProvider, so explicit hyperlinks
8+
* take precedence over regex-detected URLs.
9+
*/
10+
11+
import type { IBufferRange, ILink, ILinkProvider } from '../types';
12+
13+
/**
14+
* URL Regex Provider
15+
*
16+
* Detects plain text URLs on a single line using regex.
17+
* Does not support multi-line URLs or file paths.
18+
*
19+
* Supported protocols:
20+
* - https://, http://
21+
* - mailto:
22+
* - ftp://, ssh://, git://
23+
* - tel:, magnet:
24+
* - gemini://, gopher://, news:
25+
*/
26+
export class UrlRegexProvider implements ILinkProvider {
27+
/**
28+
* URL regex pattern
29+
* Matches common protocols followed by valid URL characters
30+
* Excludes file paths (no ./ or ../ or bare /)
31+
*/
32+
private static readonly URL_REGEX =
33+
/(?:https?:\/\/|mailto:|ftp:\/\/|ssh:\/\/|git:\/\/|tel:|magnet:|gemini:\/\/|gopher:\/\/|news:)[\w\-.~:\/?#@!$&*+,;=%]+/gi;
34+
35+
/**
36+
* Characters to strip from end of URLs
37+
* Common punctuation that's unlikely to be part of the URL
38+
*/
39+
private static readonly TRAILING_PUNCTUATION = /[.,;!?)\]]+$/;
40+
41+
constructor(private terminal: ITerminalForUrlProvider) {}
42+
43+
/**
44+
* Provide all regex-detected URLs on the given row
45+
*/
46+
provideLinks(y: number, callback: (links: ILink[] | undefined) => void): void {
47+
const links: ILink[] = [];
48+
49+
const line = this.terminal.buffer.active.getLine(y);
50+
if (!line) {
51+
callback(undefined);
52+
return;
53+
}
54+
55+
// Convert line cells to text
56+
const lineText = this.lineToText(line);
57+
58+
// Reset regex state (global flag maintains state)
59+
UrlRegexProvider.URL_REGEX.lastIndex = 0;
60+
61+
// Find all URL matches in the line
62+
let match: RegExpExecArray | null = UrlRegexProvider.URL_REGEX.exec(lineText);
63+
while (match !== null) {
64+
let url = match[0];
65+
const startX = match.index;
66+
let endX = match.index + url.length - 1; // Inclusive end
67+
68+
// Strip trailing punctuation
69+
const stripped = url.replace(UrlRegexProvider.TRAILING_PUNCTUATION, '');
70+
if (stripped.length < url.length) {
71+
url = stripped;
72+
endX = startX + url.length - 1;
73+
}
74+
75+
// Skip if URL is too short (e.g., just "http://")
76+
if (url.length > 8) {
77+
links.push({
78+
text: url,
79+
range: {
80+
start: { x: startX, y },
81+
end: { x: endX, y },
82+
},
83+
activate: (event) => {
84+
// Open link if Ctrl/Cmd is pressed
85+
if (event.ctrlKey || event.metaKey) {
86+
window.open(url, '_blank', 'noopener,noreferrer');
87+
}
88+
},
89+
});
90+
}
91+
92+
// Get next match
93+
match = UrlRegexProvider.URL_REGEX.exec(lineText);
94+
}
95+
96+
callback(links.length > 0 ? links : undefined);
97+
}
98+
99+
/**
100+
* Convert a buffer line to plain text string
101+
*/
102+
private lineToText(line: IBufferLineForUrlProvider): string {
103+
const chars: string[] = [];
104+
105+
for (let x = 0; x < line.length; x++) {
106+
const cell = line.getCell(x);
107+
if (!cell) {
108+
chars.push(' ');
109+
continue;
110+
}
111+
112+
const codepoint = cell.getCodepoint();
113+
// Skip null characters and control characters
114+
if (codepoint === 0 || codepoint < 32) {
115+
chars.push(' ');
116+
} else {
117+
chars.push(String.fromCodePoint(codepoint));
118+
}
119+
}
120+
121+
return chars.join('');
122+
}
123+
124+
dispose(): void {
125+
// No resources to clean up
126+
}
127+
}
128+
129+
/**
130+
* Minimal terminal interface required by UrlRegexProvider
131+
*/
132+
export interface ITerminalForUrlProvider {
133+
buffer: {
134+
active: {
135+
getLine(y: number): IBufferLineForUrlProvider | undefined;
136+
};
137+
};
138+
}
139+
140+
/**
141+
* Minimal buffer line interface for URL detection
142+
*/
143+
interface IBufferLineForUrlProvider {
144+
length: number;
145+
getCell(x: number):
146+
| {
147+
getCodepoint(): number;
148+
}
149+
| undefined;
150+
}

0 commit comments

Comments
 (0)