Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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
22 changes: 19 additions & 3 deletions docs/specs/terminal-escapes.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,14 @@ Rule of thumb: CSI talks to the screen, OSC talks to the application hosting the

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.

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

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

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:
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:

- `pty:data` — terminal output with supported OSCs already parsed/stripped. Feeds xterm.js.
- `pty:data` — terminal output with state-driving supported OSCs already parsed/stripped and `OSC 8` hyperlinks preserved. Feeds xterm.js.
- `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`.
- Notification-derived state is delivered through `AlertManager` calls / `alert:state` messages, not through `pty:data`.

Expand All @@ -48,6 +48,7 @@ Unknown non-iTerm2 OSC families pass through to xterm.js unchanged so xterm.js c
| `OSC 0 ; <title> ST` | Window/icon title | [terminal-state.md](terminal-state.md#supported-osc-inputs) |
| `OSC 2 ; <title> ST` | Window title | [terminal-state.md](terminal-state.md#supported-osc-inputs) |
| `OSC 7 ; file://host/path ST` | CWD (xterm-style URI) | [terminal-state.md](terminal-state.md#supported-osc-inputs) |
| `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 |
| `OSC 9 ; <message> ST` | iTerm2 legacy notification | [alert.md](alert.md#osc-9) |
| `OSC 9 ; 4 ; <state> [; <progress>] ST` | iTerm2 progress | [alert.md](alert.md#osc-94-progress) |
| `OSC 9 ; 9 ; <cwd> ST` | CWD (Windows Terminal / ConEmu) | [terminal-state.md](terminal-state.md#supported-osc-inputs) |
Expand All @@ -61,6 +62,20 @@ Unknown non-iTerm2 OSC families pass through to xterm.js unchanged so xterm.js c

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.

### OSC 8 hyperlinks

`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.

`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.

URI policy:

- Openable after confirmation: any absolute URI with a scheme, including `http:`, `https:`, `mailto:`, `file:`, and custom app schemes such as `vscode:`.
- Blocked: malformed URIs, control-character-bearing targets, and browser-executable or opaque pseudo-schemes (`javascript:`, `data:`, `blob:`, `about:`).
- 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.

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.

## Supported CSI

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.
Expand Down Expand Up @@ -135,6 +150,7 @@ This list is non-exhaustive. Any iTerm2-compatibility OSC family that Dormouse c
- xterm control sequences (OSC 0 / 2 / 7): https://invisible-island.net/xterm/ctlseqs/ctlseqs.html
- VS Code shell integration sequences (OSC 633): https://code.visualstudio.com/docs/terminal/shell-integration
- Windows Terminal CWD OSC 9;9: https://learn.microsoft.com/en-us/windows/terminal/tutorials/new-tab-same-directory
- xterm.js OSC 8 link handling: https://xtermjs.org/docs/guides/link-handling/
- kitty desktop notifications (OSC 99): https://sw.kovidgoyal.net/kitty/desktop-notifications/
- kitty keyboard protocol: https://sw.kovidgoyal.net/kitty/keyboard-protocol/
- WezTerm escape sequences (OSC 777): https://wezterm.org/escape-sequences.html
3 changes: 2 additions & 1 deletion docs/specs/transport.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ Message types live in `vscode-ext/src/message-types.ts` (the canonical schema; o
| `pty:getCwd` | Query PTY working directory (request-response via requestId) |
| `pty:getScrollback` | Query PTY scrollback buffer (request-response via requestId) |
| `pty:getShells` | Query available shells (request-response via requestId) |
| `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:`). |
| `dormouse:init` | Trigger resume: get PTY list + replay data |
| `dormouse:saveState` | Frontend persisting session state |
| `dormouse:flushSessionSaveDone` | Ack for host-triggered flush (matched by requestId) |
Expand All @@ -96,7 +97,7 @@ Message types live in `vscode-ext/src/message-types.ts` (the canonical schema; o

| Message | Purpose |
|---------|---------|
| `pty:data` | PTY output after supported OSC sequences have been parsed/stripped (routed only to owning router) |
| `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) |
| `pty:exit` | PTY process exited (with exitCode) |
| `terminal:semanticEvents` | Normalized CWD/title/prompt/command events parsed in the host from live PTY data |
| `pty:list` | List of all resumable PTYs (response to `dormouse:init`) |
Expand Down
218 changes: 218 additions & 0 deletions lib/src/components/ExternalLinkDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import { useRef } from 'react';
import { ProhibitIcon, WarningOctagonIcon, XIcon } from '@phosphor-icons/react';
import type { DisplayMatchVerdict, ExternalUriDecision } from '../lib/external-links';
import {
ModalOverlay,
ModalSurface,
modalActionButton,
modalIconButton,
useModalFocusTrap,
} from './design';

export interface ExternalLinkDialogRequest {
uri: string;
displayText: string;
verdict: DisplayMatchVerdict;
decision: ExternalUriDecision;
}

// "Open ___" button label suffix. The title is uniformly "Confirm open" and
// doesn't vary with scheme; the button is the only place the scheme noun
// appears, so the user sees what they're committing to next to their cursor.
function pickOpenButtonNoun(scheme: string, uri: string): React.ReactNode {
switch (scheme) {
case 'http':
case 'https':
return 'URL';
case 'file':
return 'file';
case 'mailto':
return 'email';
case 'tel':
return 'phone app';
case 'sms':
return 'SMS app';
default:
return <code className="font-mono">{schemePrefix(scheme, uri)}</code>;
}
}

function schemePrefix(scheme: string, uri: string): string {
return uri.slice(scheme.length + 1).startsWith('//') ? `${scheme}://` : `${scheme}:`;
}

export function ExternalLinkDialog({
request,
onCancel,
onConfirm,
}: {
request: ExternalLinkDialogRequest;
onCancel: () => void;
onConfirm: () => void;
}) {
const dialogRef = useRef<HTMLDivElement>(null);
const primaryButtonRef = useRef<HTMLButtonElement>(null);
const secondaryButtonRef = useRef<HTMLButtonElement>(null);

const openableDecision = request.decision.status === 'openable' ? request.decision : null;
const blockedDecision = request.decision.status === 'blocked' ? request.decision : null;
const displayUri = request.decision.displayUri || request.uri;
const verdict = request.verdict;
const isDeceptive = verdict === 'deceptive';
const buttonNoun = openableDecision
? pickOpenButtonNoun(openableDecision.scheme, openableDecision.uri)
: 'URL';

useModalFocusTrap(dialogRef, {
// Deceptive case: focus the copy action so a default Enter doesn't dismiss
// silently. Everywhere else: focus the safe affordance (Cancel/Close).
initialFocusRef: isDeceptive ? primaryButtonRef : secondaryButtonRef,
onEscape: onCancel,
});

const handleCopy = () => {
void navigator.clipboard.writeText(request.uri);
onCancel();
};

return (
<ModalOverlay zIndex={9999} backdrop="strong" className="px-4 py-6">
<ModalSurface
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="external-link-dialog-title"
elevation="modal"
className="w-full max-w-[34rem]"
>
<div className="flex items-start gap-3">
<h2
id="external-link-dialog-title"
className="min-w-0 flex-1 text-sm leading-5 text-foreground"
>
{isDeceptive ? (
<DeceptiveTitle displayText={request.displayText} />
) : blockedDecision ? (
<BlockedTitle reason={blockedDecision.reason} />
) : (
<OpenTitle verdict={verdict} displayText={request.displayText} />
)}
</h2>
<button
type="button"
aria-label="Close"
className={modalIconButton()}
onClick={onCancel}
>
<XIcon size={13} weight="bold" />
</button>
</div>

{/* Bordered nested box: explicit exception to the bg-only chrome rule
in DESIGN.md. The URL is the literal artifact the user is being
asked to scrutinize, and a framed box reads better than a bare
bg-shift in this high-stakes context. */}
<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">
{displayUri}
</div>

<div className="mt-4 flex justify-end gap-2 text-xs">
{isDeceptive ? (
<>
<button
ref={secondaryButtonRef}
type="button"
onClick={onCancel}
className={`${modalActionButton({ tone: 'secondary' })} min-w-[5rem]`}
>
Close
</button>
<button
ref={primaryButtonRef}
type="button"
onClick={handleCopy}
className={modalActionButton({ tone: 'primary' })}
>
Copy deceptive URL to clipboard
</button>
</>
) : openableDecision ? (
<>
<button
ref={secondaryButtonRef}
type="button"
onClick={onCancel}
className={`${modalActionButton({ tone: 'secondary' })} min-w-[5rem]`}
>
Cancel
</button>
<button
ref={primaryButtonRef}
type="button"
onClick={onConfirm}
className={`${modalActionButton({ tone: 'primary' })} min-w-[5rem]`}
>
{'Open '}{buttonNoun}
</button>
</>
) : (
<button
ref={secondaryButtonRef}
type="button"
onClick={onCancel}
className={`${modalActionButton({ tone: 'primary' })} min-w-[6rem]`}
>
Close
</button>
)}
</div>
</ModalSurface>
</ModalOverlay>
);
}

function OpenTitle({
verdict,
displayText,
}: {
verdict: DisplayMatchVerdict;
displayText: string;
}) {
if (verdict === 'plain' && displayText.trim()) {
return (
<>
Confirm open: <span className="font-semibold">{displayText.trim()}</span>
</>
);
}
return <>Confirm open</>;
}

function DeceptiveTitle({ displayText }: { displayText: string }) {
return (
<span className="flex items-start gap-1.5">
<WarningOctagonIcon
size={14}
weight="fill"
className="mt-px shrink-0 text-error"
aria-hidden
/>
<span className="leading-snug">
Deceptive link text was{' '}
<span className="font-semibold">{displayText.trim()}</span>, URL was:
</span>
</span>
);
}

function BlockedTitle({ reason }: { reason: string }) {
return (
<span className="flex items-start gap-1.5">
<ProhibitIcon size={14} weight="bold" className="mt-px shrink-0 text-error" aria-hidden />
<span className="leading-snug">
Blocked.{' '}
<span className="text-muted">{reason}</span>
</span>
</span>
);
}
51 changes: 51 additions & 0 deletions lib/src/components/ExternalLinkDialogHost.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { useCallback, useEffect, useSyncExternalStore } from 'react';
import { ExternalLinkDialog } from './ExternalLinkDialog';
import {
clearExternalLinkConfirmation,
getExternalLinkConfirmationSnapshot,
subscribeExternalLinkConfirmation,
} from '../lib/external-link-confirmation';
import { getPlatform } from '../lib/platform';

export function ExternalLinkDialogHost({
onKeyboardActiveChange,
}: {
onKeyboardActiveChange: (active: boolean) => void;
}) {
const pending = useSyncExternalStore(
subscribeExternalLinkConfirmation,
getExternalLinkConfirmationSnapshot,
);

useEffect(() => {
onKeyboardActiveChange(pending !== null);
return () => onKeyboardActiveChange(false);
}, [onKeyboardActiveChange, pending]);

const close = useCallback(() => {
clearExternalLinkConfirmation();
}, []);

const confirm = useCallback(() => {
const current = getExternalLinkConfirmationSnapshot();
if (current?.decision.status === 'openable') {
getPlatform().openExternal?.(current.decision.uri);
}
clearExternalLinkConfirmation();
}, []);

if (!pending) return null;

return (
<ExternalLinkDialog
request={{
uri: pending.uri,
displayText: pending.displayText,
verdict: pending.verdict,
decision: pending.decision,
}}
onCancel={close}
onConfirm={confirm}
/>
);
}
Loading
Loading