Skip to content

Commit 5b3b480

Browse files
committed
Enable OSC 8 hyperlink activation
1 parent 559a29d commit 5b3b480

13 files changed

Lines changed: 113 additions & 4 deletions

docs/specs/terminal-escapes.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,14 @@ Rule of thumb: CSI talks to the screen, OSC talks to the application hosting the
2020

2121
OSC sequences are introduced by `ESC ]` and terminated by either `BEL` (`\x07`) or `ST` (`ESC \`). A `BEL` that terminates an OSC is part of that OSC sequence, not a standalone bell notification. Both terminators are accepted across all supported sequences, and the parser handles split chunks across PTY reads.
2222

23-
Supported OSCs are parsed at the PTY data boundary in the platform adapter:
23+
State-driving and security-sensitive OSCs are parsed at the PTY data boundary in the platform adapter:
2424

2525
- VS Code: in the extension host (`message-router.ts` / `pty-manager.ts`), before `pty:data` is forwarded to the webview.
2626
- Standalone and fake adapters: in the frontend adapter, before xterm.js sees the bytes.
2727

28-
After parsing, supported sequences are consumed and not re-emitted. Known unsupported iTerm2/clipboard-capable OSCs listed in [Known-unimplemented iTerm2 and clipboard-capable sequences](#known-unimplemented-iterm2-and-clipboard-capable-sequences) are also consumed and ignored. The platform sends two streams to the webview:
28+
After parsing, state-driving supported sequences are consumed and not re-emitted. `OSC 8` hyperlinks are the exception: the parser leaves them in `pty:data` so xterm.js owns hyperlink regions and hover rendering, while MouseTerm supplies the activation handler. Known unsupported iTerm2/clipboard-capable OSCs listed in [Known-unimplemented iTerm2 and clipboard-capable sequences](#known-unimplemented-iterm2-and-clipboard-capable-sequences) are also consumed and ignored. The platform sends two streams to the webview:
2929

30-
- `pty:data` — terminal output with supported OSCs already parsed/stripped. Feeds xterm.js.
30+
- `pty:data` — terminal output with state-driving supported OSCs already parsed/stripped and `OSC 8` hyperlinks preserved. Feeds xterm.js.
3131
- `terminal:semanticEvents` — normalized semantic events parsed in the platform (CWD, prompt/command boundaries, titles). Feeds `TerminalPaneState`; command boundaries also feed the command-exit alert track defined in `docs/specs/alert.md`.
3232
- Notification-derived state is delivered through `AlertManager` calls / `alert:state` messages, not through `pty:data`.
3333

@@ -40,6 +40,10 @@ The parser also classifies each PTY data chunk for activity-monitor purposes:
4040

4141
Unknown non-iTerm2 OSC families pass through to xterm.js unchanged so xterm.js can handle standard terminal behavior MouseTerm does not model. Security-sensitive or iTerm2-identity-triggered OSCs must not rely on xterm.js defaults: if they are not in [Supported OSCs](#supported-oscs), MouseTerm consumes and ignores them without visible terminal garbage, clipboard access, file access, focus changes, or other side effects.
4242

43+
### OSC 8 hyperlinks
44+
45+
`OSC 8 ; <params> ; <URI> ST` starts a hyperlink region and `OSC 8 ; ; ST` closes it. `params` may be empty or include `id=<group-id>` for multi-line/shared link regions. MouseTerm does not parse the `params` or URI at the PTY boundary; it passes the sequence through to xterm.js. `terminal-lifecycle.ts` sets xterm.js's `linkHandler` so activation normalizes the URI through `normalizeExternalUri()`, allowing only `http:`, `https:`, and `mailto:` before calling the platform adapter's external-open path. VS Code revalidates in the extension host before `vscode.env.openExternal`; standalone and fake adapters also revalidate before opening.
46+
4347
## Supported OSCs
4448

4549
| Sequence | Purpose | Spec |
@@ -48,6 +52,7 @@ Unknown non-iTerm2 OSC families pass through to xterm.js unchanged so xterm.js c
4852
| `OSC 0 ; <title> ST` | Window/icon title | [terminal-state.md](terminal-state.md#supported-osc-inputs) |
4953
| `OSC 2 ; <title> ST` | Window title | [terminal-state.md](terminal-state.md#supported-osc-inputs) |
5054
| `OSC 7 ; file://host/path ST` | CWD (xterm-style URI) | [terminal-state.md](terminal-state.md#supported-osc-inputs) |
55+
| `OSC 8 ; <params> ; <URI> ST ... OSC 8 ; ; ST` | Explicit hyperlink region; passed through to xterm.js for rendering and opened through MouseTerm's external-link allowlist (`http:`, `https:`, `mailto:`). | This spec |
5156
| `OSC 9 ; <message> ST` | iTerm2 legacy notification | [alert.md](alert.md#osc-9) |
5257
| `OSC 9 ; 4 ; <state> [; <progress>] ST` | iTerm2 progress | [alert.md](alert.md#osc-94-progress) |
5358
| `OSC 9 ; 9 ; <cwd> ST` | CWD (Windows Terminal / ConEmu) | [terminal-state.md](terminal-state.md#supported-osc-inputs) |
@@ -135,6 +140,7 @@ This list is non-exhaustive. Any iTerm2-compatibility OSC family that MouseTerm
135140
- xterm control sequences (OSC 0 / 2 / 7): https://invisible-island.net/xterm/ctlseqs/ctlseqs.html
136141
- VS Code shell integration sequences (OSC 633): https://code.visualstudio.com/docs/terminal/shell-integration
137142
- Windows Terminal CWD OSC 9;9: https://learn.microsoft.com/en-us/windows/terminal/tutorials/new-tab-same-directory
143+
- xterm.js OSC 8 link handling: https://xtermjs.org/docs/guides/link-handling/
138144
- kitty desktop notifications (OSC 99): https://sw.kovidgoyal.net/kitty/desktop-notifications/
139145
- kitty keyboard protocol: https://sw.kovidgoyal.net/kitty/keyboard-protocol/
140146
- WezTerm escape sequences (OSC 777): https://wezterm.org/escape-sequences.html

docs/specs/transport.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ Message types live in `vscode-ext/src/message-types.ts` (the canonical schema; o
7777
| `pty:getCwd` | Query PTY working directory (request-response via requestId) |
7878
| `pty:getScrollback` | Query PTY scrollback buffer (request-response via requestId) |
7979
| `pty:getShells` | Query available shells (request-response via requestId) |
80+
| `mouseterm:openExternal` | Request the host to open an already-sanitized external URI from an OSC 8 hyperlink. Hosts must revalidate and only allow `http:`, `https:`, and `mailto:`. |
8081
| `mouseterm:init` | Trigger resume: get PTY list + replay data |
8182
| `mouseterm:saveState` | Frontend persisting session state |
8283
| `mouseterm:flushSessionSaveDone` | Ack for host-triggered flush (matched by requestId) |
@@ -96,7 +97,7 @@ Message types live in `vscode-ext/src/message-types.ts` (the canonical schema; o
9697

9798
| Message | Purpose |
9899
|---------|---------|
99-
| `pty:data` | PTY output after supported OSC sequences have been parsed/stripped (routed only to owning router) |
100+
| `pty:data` | PTY output after state-driving supported OSC sequences have been parsed/stripped; `OSC 8` hyperlinks are preserved for xterm.js (routed only to owning router) |
100101
| `pty:exit` | PTY process exited (with exitCode) |
101102
| `terminal:semanticEvents` | Normalized CWD/title/prompt/command events parsed in the host from live PTY data |
102103
| `pty:list` | List of all resumable PTYs (response to `mouseterm:init`) |

lib/src/lib/external-links.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { normalizeExternalUri } from './external-links';
3+
4+
describe('normalizeExternalUri', () => {
5+
it('allows http, https, and mailto URIs', () => {
6+
expect(normalizeExternalUri('https://example.com/docs?q=mouse')).toBe('https://example.com/docs?q=mouse');
7+
expect(normalizeExternalUri(' http://example.com/path ')).toBe('http://example.com/path');
8+
expect(normalizeExternalUri('mailto:support@example.com')).toBe('mailto:support@example.com');
9+
});
10+
11+
it('rejects non-external and scriptable URI schemes', () => {
12+
expect(normalizeExternalUri('file:///etc/passwd')).toBeNull();
13+
expect(normalizeExternalUri('javascript:alert(1)')).toBeNull();
14+
expect(normalizeExternalUri('data:text/html,hello')).toBeNull();
15+
});
16+
17+
it('rejects malformed or control-character-bearing input', () => {
18+
expect(normalizeExternalUri('not a url')).toBeNull();
19+
expect(normalizeExternalUri('https://example.com/\nnext')).toBeNull();
20+
expect(normalizeExternalUri('')).toBeNull();
21+
});
22+
});

lib/src/lib/external-links.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
const ALLOWED_EXTERNAL_URI_PROTOCOLS = new Set(['http:', 'https:', 'mailto:']);
2+
3+
export function normalizeExternalUri(input: string): string | null {
4+
const trimmed = input.trim();
5+
if (!trimmed || /[\x00-\x1f\x7f-\x9f]/.test(trimmed)) return null;
6+
7+
try {
8+
const uri = new URL(trimmed);
9+
return ALLOWED_EXTERNAL_URI_PROTOCOLS.has(uri.protocol) ? uri.href : null;
10+
} catch {
11+
return null;
12+
}
13+
}

lib/src/lib/platform/fake-adapter.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { AlertStateDetail, PlatformAdapter, PtyInfo } from './types';
22
import { AlertManager, type SessionStatus } from '../alert-manager';
3+
import { normalizeExternalUri } from '../external-links';
34
import {
45
applyTerminalProtocolEvents,
56
collectTerminalSemanticEvents,
@@ -198,6 +199,11 @@ export class FakePtyAdapter implements PlatformAdapter {
198199

199200
async readClipboardFilePaths(): Promise<string[] | null> { return null; }
200201
async readClipboardImageAsFilePath(): Promise<string | null> { return null; }
202+
openExternal(uri: string): void {
203+
const normalized = normalizeExternalUri(uri);
204+
if (!normalized || typeof window === 'undefined') return;
205+
window.open(normalized, '_blank', 'noopener,noreferrer');
206+
}
201207

202208
requestInit(): void {}
203209
onPtyList(_handler: (detail: { ptys: PtyInfo[] }) => void): void {}

lib/src/lib/platform/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ export interface PlatformAdapter {
3636
// Only present on adapters with a native (non-DOM) drag-drop source. Currently inert in Tauri; see diffplug/mouseterm#38 and tauri-apps/tauri#14373.
3737
onFilesDropped?(handler: (paths: string[]) => void): () => void;
3838

39+
// Open a sanitized external URI. Implementations must revalidate because
40+
// terminal output is untrusted.
41+
openExternal?(uri: string): void;
42+
3943
// PTY event listeners
4044
onPtyData(handler: (detail: { id: string; data: string }) => void): void;
4145
offPtyData(handler: (detail: { id: string; data: string }) => void): void;

lib/src/lib/platform/vscode-adapter.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,17 @@ describe('VSCodeAdapter PTY exit handling', () => {
6969
expect(postMessage).toHaveBeenCalledWith({ type: 'pty:kill', id: 'pane-1' });
7070
});
7171

72+
it('posts external hyperlink open requests to the extension host', () => {
73+
const adapter = new VSCodeAdapter();
74+
75+
adapter.openExternal('https://example.com/docs');
76+
77+
expect(postMessage).toHaveBeenCalledWith({
78+
type: 'mouseterm:openExternal',
79+
uri: 'https://example.com/docs',
80+
});
81+
});
82+
7283
it('parses replay buffers into semantic events and strips OSCs before forwarding', () => {
7384
const adapter = new VSCodeAdapter();
7485
const replays: Array<{ id: string; data: string }> = [];

lib/src/lib/platform/vscode-adapter.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,10 @@ export class VSCodeAdapter implements PlatformAdapter {
177177
);
178178
}
179179

180+
openExternal(uri: string): void {
181+
this.vscode.postMessage({ type: 'mouseterm:openExternal', uri });
182+
}
183+
180184
onPtyData(handler: (detail: { id: string; data: string }) => void): void {
181185
this.dataHandlers.add(handler);
182186
}

lib/src/lib/terminal-lifecycle.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Terminal } from '@xterm/xterm';
22
import { FitAddon } from '@xterm/addon-fit';
33
import { getPlatform } from './platform';
4+
import { normalizeExternalUri } from './external-links';
45
import { attachMouseModeObserver } from './mouse-mode-observer';
56
import {
67
bumpRenderTick,
@@ -55,6 +56,15 @@ function createXtermHost(): { terminal: Terminal; fit: FitAddon; element: HTMLDi
5556
cursorBlink: true,
5657
theme,
5758
vtExtensions: { kittyKeyboard: true },
59+
linkHandler: {
60+
activate: (event, uri) => {
61+
event.preventDefault();
62+
const normalized = normalizeExternalUri(uri);
63+
if (!normalized) return;
64+
getPlatform().openExternal?.(normalized);
65+
},
66+
allowNonHttpProtocols: true,
67+
},
5868
});
5969

6070
const fit = new FitAddon();

lib/src/lib/terminal-protocol.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,15 @@ describe('TerminalProtocolParser', () => {
150150
expect(result.events).toEqual([]);
151151
});
152152

153+
it('passes OSC 8 hyperlinks through to xterm for rendering', () => {
154+
const parser = new TerminalProtocolParser();
155+
const hyperlink = '\x1b]8;id=docs;https://example.com/docs\x1b\\docs\x1b]8;;\x1b\\';
156+
const result = parser.process(`see ${hyperlink} now`);
157+
158+
expect(result.visibleData).toBe(`see ${hyperlink} now`);
159+
expect(result.events).toEqual([]);
160+
});
161+
153162
it('strips known unsupported iTerm2 and clipboard OSC sequences', () => {
154163
const parser = new TerminalProtocolParser();
155164
const result = parser.process('a\x1b]52;c;SGVsbG8=\x07b\x1b]50;Monaco\x07c');

0 commit comments

Comments
 (0)