Skip to content
Merged
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
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
15 changes: 13 additions & 2 deletions bun.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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=="],
Expand Down Expand Up @@ -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=="],
Expand Down Expand Up @@ -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=="],

Expand All @@ -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=="],
Expand All @@ -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=="],
}
}
72 changes: 72 additions & 0 deletions demo/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,18 @@ <h3>🔍 Buffer Access API (NEW!)</h3>
</div>
</div>

<div class="feature-panel">
<h3>🔗 URL Detection (NEW!)</h3>
<p style="margin: 10px 0; font-size: 0.9em; opacity: 0.8">
Plain text URLs are automatically detected and made clickable. Hover to see cursor change,
Ctrl/Cmd+Click to open.
</p>
<div class="button-grid">
<button class="test-button" id="btn-testUrls">📝 Write Test URLs</button>
<button class="test-button" id="btn-testUrlEdgeCases">⚠️ Test URL Edge Cases</button>
</div>
</div>

<div class="warning">
<strong>⚠️ Warning: Full Filesystem Access</strong>
This demo has unrestricted access to your entire filesystem. It's meant for local
Expand Down Expand Up @@ -903,6 +915,66 @@ <h3>🔍 Buffer Access API (NEW!)</h3>
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;
}
Expand Down
6 changes: 6 additions & 0 deletions lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
150 changes: 150 additions & 0 deletions lib/providers/url-regex-provider.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading