diff --git a/README.md b/README.md
index 79ed17d..48b3e92 100644
--- a/README.md
+++ b/README.md
@@ -143,6 +143,44 @@ ws.onmessage = (event) => {
};
```
+### URL Detection
+
+Ghostty-web automatically detects and makes clickable:
+
+- **OSC 8 hyperlinks** - Explicit terminal escape sequences (e.g., from `ls --hyperlink`)
+- **Plain text URLs** - Common protocols detected via regex (https, http, mailto, ssh, git, ftp, tel, magnet)
+
+URLs are detected on hover and can be opened with Ctrl/Cmd+Click.
+
+```typescript
+// URL detection works automatically after opening terminal
+await term.open(container);
+
+// URLs in output become clickable automatically
+term.write('Visit https://github.com for code\r\n');
+term.write('Contact mailto:support@example.com\r\n');
+```
+
+**Custom Link Providers**
+
+Register custom providers to detect additional link types:
+
+```typescript
+import { UrlRegexProvider } from '@coder/ghostty-web';
+
+// Create custom provider
+const myProvider = {
+ provideLinks(y, callback) {
+ // Your detection logic here
+ const links = detectCustomLinks(y);
+ callback(links);
+ },
+};
+
+// Register after opening terminal
+term.registerLinkProvider(myProvider);
+```
+
See [AGENTS.md](AGENTS.md) for development guide and code patterns.
## Why This Approach?
diff --git a/bun.lock b/bun.lock
index c817547..a4023c7 100644
--- a/bun.lock
+++ b/bun.lock
@@ -6,6 +6,7 @@
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@types/bun": "^1.3.2",
+ "happy-dom": "^20.0.10",
"prettier": "^3.6.2",
"typescript": "^5.9.3",
"vite": "^4.5.0",
@@ -116,10 +117,12 @@
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
- "@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="],
+ "@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="],
"@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
+ "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
+
"@volar/language-core": ["@volar/language-core@2.4.23", "", { "dependencies": { "@volar/source-map": "2.4.23" } }, "sha512-hEEd5ET/oSmBC6pi1j6NaNYRWoAiDhINbT8rmwtINugR39loROSlufGdYMF9TaKGfz+ViGs1Idi3mAhnuPcoGQ=="],
"@volar/source-map": ["@volar/source-map@2.4.23", "", {}, "sha512-Z1Uc8IB57Lm6k7q6KIDu/p+JWtf3xsXJqAX/5r18hYOTpJyBn0KXUR8oTJ4WFYOcDzWC9n3IflGgHowx6U6z9Q=="],
@@ -184,6 +187,8 @@
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
+ "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=="],
+
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
@@ -266,7 +271,7 @@
"ufo": ["ufo@1.6.1", "", {}, "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA=="],
- "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
+ "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
@@ -278,6 +283,8 @@
"vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="],
+ "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
+
"yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"@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 @@
"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=="],
+ "bun-types/@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="],
+
"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=="],
+ "bun-types/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
+
"mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="],
}
}
diff --git a/demo/index.html b/demo/index.html
index 3d6a7bf..66f1df3 100644
--- a/demo/index.html
+++ b/demo/index.html
@@ -337,6 +337,18 @@
🔍 Buffer Access API (NEW!)
+
+
🔗 URL Detection (NEW!)
+
+ Plain text URLs are automatically detected and made clickable. Hover to see cursor change,
+ Ctrl/Cmd+Click to open.
+
+
+
+
+
+
+
⚠️ Warning: Full Filesystem Access
This demo has unrestricted access to your entire filesystem. It's meant for local
@@ -903,6 +915,66 @@
🔍 Buffer Access API (NEW!)
logBufferEvent(` Row ${i}: ${wrapped ? '↪ wrapped' : '↓ new line'}`, true);
}
});
+
+ // =======================================================================
+ // URL Detection Test Buttons
+ // =======================================================================
+
+ document.getElementById('btn-testUrls').addEventListener('click', () => {
+ term.write('\r\n');
+ term.write(
+ '═══════════════════════════════════════════════════════════════════════════════\r\n'
+ );
+ term.write('🔗 URL Detection Test\r\n');
+ term.write(
+ '═══════════════════════════════════════════════════════════════════════════════\r\n'
+ );
+ term.write('\r\n');
+ term.write(
+ 'Hover over URLs to see cursor change, Ctrl/Cmd+Click to open in new tab:\r\n'
+ );
+ term.write('\r\n');
+ term.write(
+ '1. HTTPS: Visit https://github.com/coder/ghostty-web for the source code\r\n'
+ );
+ term.write('2. HTTP: Check out http://example.com for more information\r\n');
+ term.write('3. Email: Contact mailto:support@example.com for help\r\n');
+ term.write('4. SSH: Connect via ssh://user@server.com:22/path\r\n');
+ term.write('5. Git: Clone git://github.com/user/repo.git to get started\r\n');
+ term.write('6. FTP: Download from ftp://files.example.com/archive.zip\r\n');
+ term.write('7. Tel: Call tel:+1-555-123-4567 for support\r\n');
+ term.write('8. Multiple URLs: https://a.com and https://b.com on same line\r\n');
+ term.write('\r\n');
+ term.write('Try clicking the URLs above!\r\n');
+ term.write('\r\n');
+ });
+
+ document.getElementById('btn-testUrlEdgeCases').addEventListener('click', () => {
+ term.write('\r\n');
+ term.write(
+ '═══════════════════════════════════════════════════════════════════════════════\r\n'
+ );
+ term.write('⚠️ URL Edge Cases\r\n');
+ term.write(
+ '═══════════════════════════════════════════════════════════════════════════════\r\n'
+ );
+ term.write('\r\n');
+ term.write('URLs should be detected correctly in these cases:\r\n');
+ term.write('\r\n');
+ term.write('1. With period: Check https://example.com. More text here.\r\n');
+ term.write('2. With comma: See https://example.com, then continue.\r\n');
+ term.write('3. With parentheses: (visit https://example.com) for details\r\n');
+ term.write('4. With exclamation: Visit https://example.com! Right now!\r\n');
+ term.write('5. With query: https://example.com?foo=bar&baz=qux\r\n');
+ term.write('6. With fragment: https://example.com/page#section-one\r\n');
+ term.write('7. With port: https://example.com:8080/api/endpoint\r\n');
+ term.write('\r\n');
+ term.write('These should NOT be detected (not URLs):\r\n');
+ term.write('8. File path: /home/user/file.txt\r\n');
+ term.write('9. Relative path: ./relative/path/to/file\r\n');
+ term.write('\r\n');
+ });
+
// Expose terminal to console for debugging
window.term = term;
}
diff --git a/lib/index.ts b/lib/index.ts
index d47ab1c..2931b45 100644
--- a/lib/index.ts
+++ b/lib/index.ts
@@ -43,3 +43,9 @@ export type { SelectionCoordinates } from './selection-manager';
// Addons
export { FitAddon } from './addons/fit';
export type { ITerminalDimensions } from './addons/fit';
+
+// Link providers
+export { OSC8LinkProvider } from './providers/osc8-link-provider';
+export { UrlRegexProvider } from './providers/url-regex-provider';
+export { LinkDetector } from './link-detector';
+export type { ILink, ILinkProvider, IBufferCellPosition } from './types';
diff --git a/lib/providers/url-regex-provider.ts b/lib/providers/url-regex-provider.ts
new file mode 100644
index 0000000..82dad11
--- /dev/null
+++ b/lib/providers/url-regex-provider.ts
@@ -0,0 +1,150 @@
+/**
+ * URL Regex Link Provider
+ *
+ * Detects plain text URLs using regex pattern matching.
+ * Supports common protocols but excludes file paths.
+ *
+ * This provider runs after OSC8LinkProvider, so explicit hyperlinks
+ * take precedence over regex-detected URLs.
+ */
+
+import type { IBufferRange, ILink, ILinkProvider } from '../types';
+
+/**
+ * URL Regex Provider
+ *
+ * Detects plain text URLs on a single line using regex.
+ * Does not support multi-line URLs or file paths.
+ *
+ * Supported protocols:
+ * - https://, http://
+ * - mailto:
+ * - ftp://, ssh://, git://
+ * - tel:, magnet:
+ * - gemini://, gopher://, news:
+ */
+export class UrlRegexProvider implements ILinkProvider {
+ /**
+ * URL regex pattern
+ * Matches common protocols followed by valid URL characters
+ * Excludes file paths (no ./ or ../ or bare /)
+ */
+ private static readonly URL_REGEX =
+ /(?:https?:\/\/|mailto:|ftp:\/\/|ssh:\/\/|git:\/\/|tel:|magnet:|gemini:\/\/|gopher:\/\/|news:)[\w\-.~:\/?#@!$&*+,;=%]+/gi;
+
+ /**
+ * Characters to strip from end of URLs
+ * Common punctuation that's unlikely to be part of the URL
+ */
+ private static readonly TRAILING_PUNCTUATION = /[.,;!?)\]]+$/;
+
+ constructor(private terminal: ITerminalForUrlProvider) {}
+
+ /**
+ * Provide all regex-detected URLs on the given row
+ */
+ provideLinks(y: number, callback: (links: ILink[] | undefined) => void): void {
+ const links: ILink[] = [];
+
+ const line = this.terminal.buffer.active.getLine(y);
+ if (!line) {
+ callback(undefined);
+ return;
+ }
+
+ // Convert line cells to text
+ const lineText = this.lineToText(line);
+
+ // Reset regex state (global flag maintains state)
+ UrlRegexProvider.URL_REGEX.lastIndex = 0;
+
+ // Find all URL matches in the line
+ let match: RegExpExecArray | null = UrlRegexProvider.URL_REGEX.exec(lineText);
+ while (match !== null) {
+ let url = match[0];
+ const startX = match.index;
+ let endX = match.index + url.length - 1; // Inclusive end
+
+ // Strip trailing punctuation
+ const stripped = url.replace(UrlRegexProvider.TRAILING_PUNCTUATION, '');
+ if (stripped.length < url.length) {
+ url = stripped;
+ endX = startX + url.length - 1;
+ }
+
+ // Skip if URL is too short (e.g., just "http://")
+ if (url.length > 8) {
+ links.push({
+ text: url,
+ range: {
+ start: { x: startX, y },
+ end: { x: endX, y },
+ },
+ activate: (event) => {
+ // Open link if Ctrl/Cmd is pressed
+ if (event.ctrlKey || event.metaKey) {
+ window.open(url, '_blank', 'noopener,noreferrer');
+ }
+ },
+ });
+ }
+
+ // Get next match
+ match = UrlRegexProvider.URL_REGEX.exec(lineText);
+ }
+
+ callback(links.length > 0 ? links : undefined);
+ }
+
+ /**
+ * Convert a buffer line to plain text string
+ */
+ private lineToText(line: IBufferLineForUrlProvider): string {
+ const chars: string[] = [];
+
+ for (let x = 0; x < line.length; x++) {
+ const cell = line.getCell(x);
+ if (!cell) {
+ chars.push(' ');
+ continue;
+ }
+
+ const codepoint = cell.getCodepoint();
+ // Skip null characters and control characters
+ if (codepoint === 0 || codepoint < 32) {
+ chars.push(' ');
+ } else {
+ chars.push(String.fromCodePoint(codepoint));
+ }
+ }
+
+ return chars.join('');
+ }
+
+ dispose(): void {
+ // No resources to clean up
+ }
+}
+
+/**
+ * Minimal terminal interface required by UrlRegexProvider
+ */
+export interface ITerminalForUrlProvider {
+ buffer: {
+ active: {
+ getLine(y: number): IBufferLineForUrlProvider | undefined;
+ };
+ };
+}
+
+/**
+ * Minimal buffer line interface for URL detection
+ */
+interface IBufferLineForUrlProvider {
+ length: number;
+ getCell(x: number):
+ | {
+ getCodepoint(): number;
+ }
+ | undefined;
+}
diff --git a/lib/renderer.ts b/lib/renderer.ts
index 310c3e0..1a2cf0a 100644
--- a/lib/renderer.ts
+++ b/lib/renderer.ts
@@ -108,6 +108,16 @@ export class CanvasRenderer {
private hoveredHyperlinkId: number = 0;
private previousHoveredHyperlinkId: number = 0;
+ // Regex link hover tracking (for links without hyperlink_id)
+ private hoveredLinkRange: { startX: number; startY: number; endX: number; endY: number } | null =
+ null;
+ private previousHoveredLinkRange: {
+ startX: number;
+ startY: number;
+ endX: number;
+ endY: number;
+ } | null = null;
+
constructor(canvas: HTMLCanvasElement, options: RendererOptions = {}) {
this.canvas = canvas;
const ctx = canvas.getContext('2d', { alpha: false });
@@ -313,6 +323,8 @@ export class CanvasRenderer {
// Track rows with hyperlinks that need redraw when hover changes
const hyperlinkRows = new Set();
const hyperlinkChanged = this.hoveredHyperlinkId !== this.previousHoveredHyperlinkId;
+ const linkRangeChanged =
+ JSON.stringify(this.hoveredLinkRange) !== JSON.stringify(this.previousHoveredLinkRange);
if (hyperlinkChanged) {
// Find rows containing the old or new hovered hyperlink
@@ -352,6 +364,27 @@ export class CanvasRenderer {
this.previousHoveredHyperlinkId = this.hoveredHyperlinkId;
}
+ // Track rows affected by link range changes (for regex URLs)
+ if (linkRangeChanged) {
+ // Add rows from old range
+ if (this.previousHoveredLinkRange) {
+ for (
+ let y = this.previousHoveredLinkRange.startY;
+ y <= this.previousHoveredLinkRange.endY;
+ y++
+ ) {
+ hyperlinkRows.add(y);
+ }
+ }
+ // Add rows from new range
+ if (this.hoveredLinkRange) {
+ for (let y = this.hoveredLinkRange.startY; y <= this.hoveredLinkRange.endY; y++) {
+ hyperlinkRows.add(y);
+ }
+ }
+ this.previousHoveredLinkRange = this.hoveredLinkRange;
+ }
+
// Track if anything was actually rendered
let anyLinesRendered = false;
@@ -524,7 +557,7 @@ export class CanvasRenderer {
this.ctx.stroke();
}
- // Draw hyperlink underline
+ // Draw hyperlink underline (for OSC8 hyperlinks)
if (cell.hyperlink_id > 0) {
const isHovered = cell.hyperlink_id === this.hoveredHyperlinkId;
@@ -539,6 +572,26 @@ export class CanvasRenderer {
this.ctx.stroke();
}
}
+
+ // Draw regex link underline (for plain text URLs)
+ if (this.hoveredLinkRange) {
+ const range = this.hoveredLinkRange;
+ // Check if this cell is within the hovered link range
+ const isInRange =
+ (y === range.startY && x >= range.startX && (y < range.endY || x <= range.endX)) ||
+ (y > range.startY && y < range.endY) ||
+ (y === range.endY && x <= range.endX && (y > range.startY || x >= range.startX));
+
+ if (isInRange) {
+ const underlineY = cellY + this.metrics.baseline + 2;
+ this.ctx.strokeStyle = '#4A90E2'; // Blue underline on hover
+ this.ctx.lineWidth = 1;
+ this.ctx.beginPath();
+ this.ctx.moveTo(cellX, underlineY);
+ this.ctx.lineTo(cellX + cellWidth, underlineY);
+ this.ctx.stroke();
+ }
+ }
}
/**
@@ -733,6 +786,21 @@ export class CanvasRenderer {
this.hoveredHyperlinkId = hyperlinkId;
}
+ /**
+ * Set the currently hovered link range for rendering underlines (for regex-detected URLs)
+ * Pass null to clear the hover state
+ */
+ public setHoveredLinkRange(
+ range: {
+ startX: number;
+ startY: number;
+ endX: number;
+ endY: number;
+ } | null
+ ): void {
+ this.hoveredLinkRange = range;
+ }
+
/**
* Get character cell width (for coordinate conversion)
*/
diff --git a/lib/terminal.ts b/lib/terminal.ts
index cf883b7..110f7d5 100644
--- a/lib/terminal.ts
+++ b/lib/terminal.ts
@@ -28,6 +28,7 @@ import type {
} from './interfaces';
import { LinkDetector } from './link-detector';
import { OSC8LinkProvider } from './providers/osc8-link-provider';
+import { UrlRegexProvider } from './providers/url-regex-provider';
import { CanvasRenderer } from './renderer';
import { SelectionManager } from './selection-manager';
import type { ILink, ILinkProvider } from './types';
@@ -266,8 +267,11 @@ export class Terminal implements ITerminalCore {
// Initialize link detection system
this.linkDetector = new LinkDetector(this);
- // Register OSC 8 hyperlink provider
+ // Register link providers
+ // OSC8 first (explicit hyperlinks take precedence)
this.linkDetector.registerProvider(new OSC8LinkProvider(this));
+ // URL regex second (fallback for plain text URLs)
+ this.linkDetector.registerProvider(new UrlRegexProvider(this));
// Setup mouse event handling for links and scrollbar
// Use capture phase to intercept scrollbar clicks before SelectionManager
@@ -987,6 +991,32 @@ export class Terminal implements ITerminalCore {
if (this.element) {
this.element.style.cursor = link ? 'pointer' : 'text';
}
+
+ // Update renderer for underline (for regex URLs without hyperlink_id)
+ if (this.renderer) {
+ if (link) {
+ // Convert buffer coordinates to viewport coordinates
+ const scrollbackLength = this.wasmTerm?.getScrollbackLength() || 0;
+
+ // Calculate viewport Y for start and end positions
+ const startViewportY = link.range.start.y - scrollbackLength + this.viewportY;
+ const endViewportY = link.range.end.y - scrollbackLength + this.viewportY;
+
+ // Only show underline if link is visible in viewport
+ if (startViewportY < this.rows && endViewportY >= 0) {
+ this.renderer.setHoveredLinkRange({
+ startX: link.range.start.x,
+ startY: Math.max(0, startViewportY),
+ endX: link.range.end.x,
+ endY: Math.min(this.rows - 1, endViewportY),
+ });
+ } else {
+ this.renderer.setHoveredLinkRange(null);
+ }
+ } else {
+ this.renderer.setHoveredLinkRange(null);
+ }
+ }
}
})
.catch((err) => {
@@ -1006,6 +1036,8 @@ export class Terminal implements ITerminalCore {
// The 60fps render loop will pick up the change automatically
}
+ // Clear regex link underline
+ this.renderer.setHoveredLinkRange(null);
}
if (this.currentHoveredLink) {
diff --git a/lib/url-detection.test.ts b/lib/url-detection.test.ts
new file mode 100644
index 0000000..f31dfc2
--- /dev/null
+++ b/lib/url-detection.test.ts
@@ -0,0 +1,187 @@
+/**
+ * URL Detection Tests
+ *
+ * Tests for the UrlRegexProvider to ensure plain text URLs
+ * are correctly detected and made clickable.
+ */
+
+import { describe, expect, test } from 'bun:test';
+import { UrlRegexProvider } from './providers/url-regex-provider';
+import type { ILink } from './types';
+
+/**
+ * Mock terminal for testing
+ */
+function createMockTerminal(lineText: string) {
+ const cells = Array.from(lineText).map((char) => ({
+ getCodepoint: () => char.codePointAt(0) || 0,
+ }));
+
+ return {
+ buffer: {
+ active: {
+ getLine: (y: number) => {
+ if (y !== 0) return undefined;
+ return {
+ length: cells.length,
+ getCell: (x: number) => cells[x],
+ };
+ },
+ },
+ },
+ };
+}
+
+/**
+ * Helper to get links from provider
+ */
+function getLinks(lineText: string): Promise {
+ const terminal = createMockTerminal(lineText) as any;
+ const provider = new UrlRegexProvider(terminal);
+
+ return new Promise((resolve) => {
+ provider.provideLinks(0, resolve);
+ });
+}
+
+describe('URL Detection', () => {
+ test('detects HTTPS URLs', async () => {
+ const links = await getLinks('Visit https://github.com for code');
+ expect(links).toBeDefined();
+ expect(links?.length).toBe(1);
+ expect(links?.[0].text).toBe('https://github.com');
+ expect(links?.[0].range.start.x).toBe(6);
+ // End is inclusive - last character is at index 23 (https://github.com is 19 chars, starts at 6)
+ expect(links?.[0].range.end.x).toBe(23);
+ });
+
+ test('detects HTTP URLs', async () => {
+ const links = await getLinks('Check http://example.com');
+ expect(links).toBeDefined();
+ expect(links?.length).toBe(1);
+ expect(links?.[0].text).toBe('http://example.com');
+ });
+
+ test('detects mailto: links', async () => {
+ const links = await getLinks('Email: mailto:test@example.com');
+ expect(links).toBeDefined();
+ expect(links?.length).toBe(1);
+ expect(links?.[0].text).toBe('mailto:test@example.com');
+ });
+
+ test('detects ssh:// URLs', async () => {
+ const links = await getLinks('Connect via ssh://user@server.com');
+ expect(links).toBeDefined();
+ expect(links?.length).toBe(1);
+ expect(links?.[0].text).toBe('ssh://user@server.com');
+ });
+
+ test('detects git:// URLs', async () => {
+ const links = await getLinks('Clone git://github.com/repo.git');
+ expect(links).toBeDefined();
+ expect(links?.length).toBe(1);
+ expect(links?.[0].text).toBe('git://github.com/repo.git');
+ });
+
+ test('detects ftp:// URLs', async () => {
+ const links = await getLinks('Download ftp://files.example.com/file');
+ expect(links).toBeDefined();
+ expect(links?.length).toBe(1);
+ expect(links?.[0].text).toBe('ftp://files.example.com/file');
+ });
+
+ test('strips trailing period', async () => {
+ const links = await getLinks('Check https://example.com.');
+ expect(links).toBeDefined();
+ expect(links?.length).toBe(1);
+ expect(links?.[0].text).toBe('https://example.com');
+ // Should NOT include the trailing period
+ expect(links?.[0].text.endsWith('.')).toBe(false);
+ });
+
+ test('strips trailing comma', async () => {
+ const links = await getLinks('See https://example.com, or else');
+ expect(links).toBeDefined();
+ expect(links?.length).toBe(1);
+ expect(links?.[0].text).toBe('https://example.com');
+ });
+
+ test('strips trailing parenthesis', async () => {
+ const links = await getLinks('(see https://example.com)');
+ expect(links).toBeDefined();
+ expect(links?.length).toBe(1);
+ expect(links?.[0].text).toBe('https://example.com');
+ });
+
+ test('strips trailing exclamation', async () => {
+ const links = await getLinks('Visit https://example.com!');
+ expect(links).toBeDefined();
+ expect(links?.length).toBe(1);
+ expect(links?.[0].text).toBe('https://example.com');
+ });
+
+ test('handles multiple URLs on same line', async () => {
+ const links = await getLinks('https://a.com and https://b.com');
+ expect(links).toBeDefined();
+ expect(links?.length).toBe(2);
+ expect(links?.[0].text).toBe('https://a.com');
+ expect(links?.[1].text).toBe('https://b.com');
+ });
+
+ test('returns undefined when no URL present', async () => {
+ const links = await getLinks('No URLs here');
+ expect(links).toBeUndefined();
+ });
+
+ test('handles URLs with query parameters', async () => {
+ const links = await getLinks('https://example.com?foo=bar&baz=qux');
+ expect(links).toBeDefined();
+ expect(links?.length).toBe(1);
+ expect(links?.[0].text).toBe('https://example.com?foo=bar&baz=qux');
+ });
+
+ test('handles URLs with fragments', async () => {
+ const links = await getLinks('https://example.com/page#section');
+ expect(links).toBeDefined();
+ expect(links?.length).toBe(1);
+ expect(links?.[0].text).toBe('https://example.com/page#section');
+ });
+
+ test('handles URLs with ports', async () => {
+ const links = await getLinks('https://example.com:8080/path');
+ expect(links).toBeDefined();
+ expect(links?.length).toBe(1);
+ expect(links?.[0].text).toBe('https://example.com:8080/path');
+ });
+
+ test('does not detect file paths', async () => {
+ const links = await getLinks('/home/user/file.txt');
+ expect(links).toBeUndefined();
+ });
+
+ test('does not detect relative paths', async () => {
+ const links = await getLinks('./relative/path');
+ expect(links).toBeUndefined();
+ });
+
+ test('link has activate function', async () => {
+ const links = await getLinks('https://example.com');
+ expect(links).toBeDefined();
+ expect(links?.length).toBe(1);
+ expect(typeof links?.[0].activate).toBe('function');
+ });
+
+ test('detects tel: URLs', async () => {
+ const links = await getLinks('Call tel:+1234567890');
+ expect(links).toBeDefined();
+ expect(links?.length).toBe(1);
+ expect(links?.[0].text).toBe('tel:+1234567890');
+ });
+
+ test('detects magnet: URLs', async () => {
+ const links = await getLinks('Download magnet:?xt=urn:btih:abc123');
+ expect(links).toBeDefined();
+ expect(links?.length).toBe(1);
+ expect(links?.[0].text).toContain('magnet:?xt=urn:btih:abc123');
+ });
+});
diff --git a/package.json b/package.json
index f880d41..17f4e07 100644
--- a/package.json
+++ b/package.json
@@ -62,6 +62,7 @@
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@types/bun": "^1.3.2",
+ "happy-dom": "^20.0.10",
"prettier": "^3.6.2",
"typescript": "^5.9.3",
"vite": "^4.5.0",