Skip to content

Commit b13543c

Browse files
authored
Add OSC 8 hyperlink activation with confirmation dialog (#75)
2 parents 5e7a4f1 + 023a1bd commit b13543c

23 files changed

Lines changed: 1033 additions & 48 deletions

docs/specs/terminal-escapes.md

Lines changed: 19 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 Dormouse supplies the activation-confirmation 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

@@ -48,6 +48,7 @@ Unknown non-iTerm2 OSC families pass through to xterm.js unchanged so xterm.js c
4848
| `OSC 0 ; <title> ST` | Window/icon title | [terminal-state.md](terminal-state.md#supported-osc-inputs) |
4949
| `OSC 2 ; <title> ST` | Window title | [terminal-state.md](terminal-state.md#supported-osc-inputs) |
5050
| `OSC 7 ; file://host/path ST` | CWD (xterm-style URI) | [terminal-state.md](terminal-state.md#supported-osc-inputs) |
51+
| `OSC 8 ; <params> ; <URI> ST ... OSC 8 ; ; ST` | Explicit hyperlink region; passed through to xterm.js for rendering, then opened only after Dormouse shows the real target in a confirmation dialog. | This spec |
5152
| `OSC 9 ; <message> ST` | iTerm2 legacy notification | [alert.md](alert.md#osc-9) |
5253
| `OSC 9 ; 4 ; <state> [; <progress>] ST` | iTerm2 progress | [alert.md](alert.md#osc-94-progress) |
5354
| `OSC 9 ; 9 ; <cwd> ST` | CWD (Windows Terminal / ConEmu) | [terminal-state.md](terminal-state.md#supported-osc-inputs) |
@@ -61,6 +62,20 @@ Unknown non-iTerm2 OSC families pass through to xterm.js unchanged so xterm.js c
6162

6263
Some sequences are dual-purpose. The notification rows for `OSC 9 ; <message> ST`, `OSC 99` (`p=title`/`p=body`), and `OSC 777 ; notify` also feed the title-candidate channel in `terminal-state.md` — see its [Title candidate diagnostics](terminal-state.md#supported-osc-inputs) table. Only the OSC 9 *message* form can become a header/door label; OSC 99 and OSC 777 candidates are stored for the diagnostic popup only. The OSC 9 *progress* form (`OSC 9 ; 4`) carries no text and never contributes a title candidate.
6364

65+
### OSC 8 hyperlinks
66+
67+
`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. Dormouse does not parse the `params` or URI at the PTY boundary; it passes the sequence through to xterm.js.
68+
69+
`terminal-lifecycle.ts` sets xterm.js's `linkHandler` so activation never opens directly. Every click opens Dormouse's external-link confirmation dialog first. The dialog must show the full target URI from the OSC sequence, the URI scheme, and a primary `Open URL` action plus a cancel action. Cancel is the safe default. Long targets wrap and scroll instead of truncating so users can inspect deceptive link text.
70+
71+
URI policy:
72+
73+
- Openable after confirmation: any absolute URI with a scheme, including `http:`, `https:`, `mailto:`, `file:`, and custom app schemes such as `vscode:`.
74+
- Blocked: malformed URIs, control-character-bearing targets, and browser-executable or opaque pseudo-schemes (`javascript:`, `data:`, `blob:`, `about:`).
75+
- Blocked targets are not silently dropped. They still open the dialog in a non-openable state with the full target and reason visible, and `Open URL` disabled.
76+
77+
VS Code revalidates in the extension host before `vscode.env.openExternal`; standalone and fake adapters also revalidate before opening. The frontend dialog is a user-consent affordance, not the security boundary.
78+
6479
## Supported CSI
6580

6681
The vast majority of CSI handling is delegated to xterm.js. Dormouse only intervenes in the cases below — either to answer a query itself (so the response shape is under our control), to observe a state change xterm.js processes, to enable an xterm.js feature, or to filter replay output.
@@ -135,6 +150,7 @@ This list is non-exhaustive. Any iTerm2-compatibility OSC family that Dormouse c
135150
- xterm control sequences (OSC 0 / 2 / 7): https://invisible-island.net/xterm/ctlseqs/ctlseqs.html
136151
- VS Code shell integration sequences (OSC 633): https://code.visualstudio.com/docs/terminal/shell-integration
137152
- Windows Terminal CWD OSC 9;9: https://learn.microsoft.com/en-us/windows/terminal/tutorials/new-tab-same-directory
153+
- xterm.js OSC 8 link handling: https://xtermjs.org/docs/guides/link-handling/
138154
- kitty desktop notifications (OSC 99): https://sw.kovidgoyal.net/kitty/desktop-notifications/
139155
- kitty keyboard protocol: https://sw.kovidgoyal.net/kitty/keyboard-protocol/
140156
- 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+
| `dormouse:openExternal` | Request the host to open a user-confirmed external URI from an OSC 8 hyperlink. Hosts must revalidate and reject malformed, control-character-bearing, or blocked pseudo-scheme targets (`javascript:`, `data:`, `blob:`, `about:`). |
8081
| `dormouse:init` | Trigger resume: get PTY list + replay data |
8182
| `dormouse:saveState` | Frontend persisting session state |
8283
| `dormouse: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 `dormouse:init`) |
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import { useRef } from 'react';
2+
import { ProhibitIcon, WarningOctagonIcon, XIcon } from '@phosphor-icons/react';
3+
import type { DisplayMatchVerdict, ExternalUriDecision } from '../lib/external-links';
4+
import {
5+
ModalOverlay,
6+
ModalSurface,
7+
modalActionButton,
8+
modalIconButton,
9+
useModalFocusTrap,
10+
} from './design';
11+
12+
export interface ExternalLinkDialogRequest {
13+
uri: string;
14+
displayText: string;
15+
verdict: DisplayMatchVerdict;
16+
decision: ExternalUriDecision;
17+
}
18+
19+
// "Open ___" button label suffix. The title is uniformly "Confirm open" and
20+
// doesn't vary with scheme; the button is the only place the scheme noun
21+
// appears, so the user sees what they're committing to next to their cursor.
22+
function pickOpenButtonNoun(scheme: string, uri: string): React.ReactNode {
23+
switch (scheme) {
24+
case 'http':
25+
case 'https':
26+
return 'URL';
27+
case 'file':
28+
return 'file';
29+
case 'mailto':
30+
return 'email';
31+
case 'tel':
32+
return 'phone app';
33+
case 'sms':
34+
return 'SMS app';
35+
default:
36+
return <code className="font-mono">{schemePrefix(scheme, uri)}</code>;
37+
}
38+
}
39+
40+
function schemePrefix(scheme: string, uri: string): string {
41+
return uri.slice(scheme.length + 1).startsWith('//') ? `${scheme}://` : `${scheme}:`;
42+
}
43+
44+
export function ExternalLinkDialog({
45+
request,
46+
onCancel,
47+
onConfirm,
48+
}: {
49+
request: ExternalLinkDialogRequest;
50+
onCancel: () => void;
51+
onConfirm: () => void;
52+
}) {
53+
const dialogRef = useRef<HTMLDivElement>(null);
54+
const primaryButtonRef = useRef<HTMLButtonElement>(null);
55+
const secondaryButtonRef = useRef<HTMLButtonElement>(null);
56+
57+
const openableDecision = request.decision.status === 'openable' ? request.decision : null;
58+
const blockedDecision = request.decision.status === 'blocked' ? request.decision : null;
59+
const displayUri = request.decision.displayUri || request.uri;
60+
const verdict = request.verdict;
61+
const isDeceptive = verdict === 'deceptive';
62+
const buttonNoun = openableDecision
63+
? pickOpenButtonNoun(openableDecision.scheme, openableDecision.uri)
64+
: 'URL';
65+
66+
useModalFocusTrap(dialogRef, {
67+
// Deceptive case: focus the copy action so a default Enter doesn't dismiss
68+
// silently. Everywhere else: focus the safe affordance (Cancel/Close).
69+
initialFocusRef: isDeceptive ? primaryButtonRef : secondaryButtonRef,
70+
onEscape: onCancel,
71+
});
72+
73+
const handleCopy = () => {
74+
void navigator.clipboard.writeText(request.uri);
75+
onCancel();
76+
};
77+
78+
return (
79+
<ModalOverlay zIndex={9999} backdrop="strong" className="px-4 py-6">
80+
<ModalSurface
81+
ref={dialogRef}
82+
role="dialog"
83+
aria-modal="true"
84+
aria-labelledby="external-link-dialog-title"
85+
elevation="modal"
86+
className="w-full max-w-[34rem]"
87+
>
88+
<div className="flex items-start gap-3">
89+
<h2
90+
id="external-link-dialog-title"
91+
className="min-w-0 flex-1 text-sm leading-5 text-foreground"
92+
>
93+
{isDeceptive ? (
94+
<DeceptiveTitle displayText={request.displayText} />
95+
) : blockedDecision ? (
96+
<BlockedTitle reason={blockedDecision.reason} />
97+
) : (
98+
<OpenTitle verdict={verdict} displayText={request.displayText} />
99+
)}
100+
</h2>
101+
<button
102+
type="button"
103+
aria-label="Close"
104+
className={modalIconButton()}
105+
onClick={onCancel}
106+
>
107+
<XIcon size={13} weight="bold" />
108+
</button>
109+
</div>
110+
111+
{/* Bordered nested box: explicit exception to the bg-only chrome rule
112+
in DESIGN.md. The URL is the literal artifact the user is being
113+
asked to scrutinize, and a framed box reads better than a bare
114+
bg-shift in this high-stakes context. */}
115+
<div className="mt-3 max-h-40 overflow-auto whitespace-pre-wrap break-all rounded border border-border bg-app-bg px-2.5 py-2 text-sm leading-relaxed text-foreground">
116+
{displayUri}
117+
</div>
118+
119+
<div className="mt-4 flex justify-end gap-2 text-xs">
120+
{isDeceptive ? (
121+
<>
122+
<button
123+
ref={secondaryButtonRef}
124+
type="button"
125+
onClick={onCancel}
126+
className={`${modalActionButton({ tone: 'secondary' })} min-w-[5rem]`}
127+
>
128+
Close
129+
</button>
130+
<button
131+
ref={primaryButtonRef}
132+
type="button"
133+
onClick={handleCopy}
134+
className={modalActionButton({ tone: 'primary' })}
135+
>
136+
Copy deceptive URL to clipboard
137+
</button>
138+
</>
139+
) : openableDecision ? (
140+
<>
141+
<button
142+
ref={secondaryButtonRef}
143+
type="button"
144+
onClick={onCancel}
145+
className={`${modalActionButton({ tone: 'secondary' })} min-w-[5rem]`}
146+
>
147+
Cancel
148+
</button>
149+
<button
150+
ref={primaryButtonRef}
151+
type="button"
152+
onClick={onConfirm}
153+
className={`${modalActionButton({ tone: 'primary' })} min-w-[5rem]`}
154+
>
155+
{'Open '}{buttonNoun}
156+
</button>
157+
</>
158+
) : (
159+
<button
160+
ref={secondaryButtonRef}
161+
type="button"
162+
onClick={onCancel}
163+
className={`${modalActionButton({ tone: 'primary' })} min-w-[6rem]`}
164+
>
165+
Close
166+
</button>
167+
)}
168+
</div>
169+
</ModalSurface>
170+
</ModalOverlay>
171+
);
172+
}
173+
174+
function OpenTitle({
175+
verdict,
176+
displayText,
177+
}: {
178+
verdict: DisplayMatchVerdict;
179+
displayText: string;
180+
}) {
181+
if (verdict === 'plain' && displayText.trim()) {
182+
return (
183+
<>
184+
Confirm open: <span className="font-semibold">{displayText.trim()}</span>
185+
</>
186+
);
187+
}
188+
return <>Confirm open</>;
189+
}
190+
191+
function DeceptiveTitle({ displayText }: { displayText: string }) {
192+
return (
193+
<span className="flex items-start gap-1.5">
194+
<WarningOctagonIcon
195+
size={14}
196+
weight="fill"
197+
className="mt-px shrink-0 text-error"
198+
aria-hidden
199+
/>
200+
<span className="leading-snug">
201+
Deceptive link text was{' '}
202+
<span className="font-semibold">{displayText.trim()}</span>, URL was:
203+
</span>
204+
</span>
205+
);
206+
}
207+
208+
function BlockedTitle({ reason }: { reason: string }) {
209+
return (
210+
<span className="flex items-start gap-1.5">
211+
<ProhibitIcon size={14} weight="bold" className="mt-px shrink-0 text-error" aria-hidden />
212+
<span className="leading-snug">
213+
Blocked.{' '}
214+
<span className="text-muted">{reason}</span>
215+
</span>
216+
</span>
217+
);
218+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { useCallback, useEffect, useSyncExternalStore } from 'react';
2+
import { ExternalLinkDialog } from './ExternalLinkDialog';
3+
import {
4+
clearExternalLinkConfirmation,
5+
getExternalLinkConfirmationSnapshot,
6+
subscribeExternalLinkConfirmation,
7+
} from '../lib/external-link-confirmation';
8+
import { getPlatform } from '../lib/platform';
9+
10+
export function ExternalLinkDialogHost({
11+
onKeyboardActiveChange,
12+
}: {
13+
onKeyboardActiveChange: (active: boolean) => void;
14+
}) {
15+
const pending = useSyncExternalStore(
16+
subscribeExternalLinkConfirmation,
17+
getExternalLinkConfirmationSnapshot,
18+
);
19+
20+
useEffect(() => {
21+
onKeyboardActiveChange(pending !== null);
22+
return () => onKeyboardActiveChange(false);
23+
}, [onKeyboardActiveChange, pending]);
24+
25+
const close = useCallback(() => {
26+
clearExternalLinkConfirmation();
27+
}, []);
28+
29+
const confirm = useCallback(() => {
30+
const current = getExternalLinkConfirmationSnapshot();
31+
if (current?.decision.status === 'openable') {
32+
getPlatform().openExternal?.(current.decision.uri);
33+
}
34+
clearExternalLinkConfirmation();
35+
}, []);
36+
37+
if (!pending) return null;
38+
39+
return (
40+
<ExternalLinkDialog
41+
request={{
42+
uri: pending.uri,
43+
displayText: pending.displayText,
44+
verdict: pending.verdict,
45+
decision: pending.decision,
46+
}}
47+
onCancel={close}
48+
onConfirm={confirm}
49+
/>
50+
);
51+
}

0 commit comments

Comments
 (0)