diff --git a/docs/specs/terminal-escapes.md b/docs/specs/terminal-escapes.md
index e8b56a49..603fc6e5 100644
--- a/docs/specs/terminal-escapes.md
+++ b/docs/specs/terminal-escapes.md
@@ -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`.
@@ -48,6 +48,7 @@ Unknown non-iTerm2 OSC families pass through to xterm.js unchanged so xterm.js c
| `OSC 0 ;
ST` | Window/icon title | [terminal-state.md](terminal-state.md#supported-osc-inputs) |
| `OSC 2 ; 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 ; ; 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 ; ST` | iTerm2 legacy notification | [alert.md](alert.md#osc-9) |
| `OSC 9 ; 4 ; [; ] ST` | iTerm2 progress | [alert.md](alert.md#osc-94-progress) |
| `OSC 9 ; 9 ; 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
Some sequences are dual-purpose. The notification rows for `OSC 9 ; 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 ; ; ST` starts a hyperlink region and `OSC 8 ; ; ST` closes it. `params` may be empty or include `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.
@@ -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
diff --git a/docs/specs/transport.md b/docs/specs/transport.md
index 85e424cb..980ca208 100644
--- a/docs/specs/transport.md
+++ b/docs/specs/transport.md
@@ -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) |
@@ -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`) |
diff --git a/lib/src/components/ExternalLinkDialog.tsx b/lib/src/components/ExternalLinkDialog.tsx
new file mode 100644
index 00000000..c90cf3e6
--- /dev/null
+++ b/lib/src/components/ExternalLinkDialog.tsx
@@ -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 {schemePrefix(scheme, uri)};
+ }
+}
+
+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(null);
+ const primaryButtonRef = useRef(null);
+ const secondaryButtonRef = useRef(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 (
+
+
+
+
+ {isDeceptive ? (
+
+ ) : blockedDecision ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+ {/* 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. */}
+
+ {displayUri}
+
+
+
+ {isDeceptive ? (
+ <>
+
+ Close
+
+
+ Copy deceptive URL to clipboard
+
+ >
+ ) : openableDecision ? (
+ <>
+
+ Cancel
+
+
+ {'Open '}{buttonNoun}
+
+ >
+ ) : (
+
+ Close
+
+ )}
+
+
+
+ );
+}
+
+function OpenTitle({
+ verdict,
+ displayText,
+}: {
+ verdict: DisplayMatchVerdict;
+ displayText: string;
+}) {
+ if (verdict === 'plain' && displayText.trim()) {
+ return (
+ <>
+ Confirm open: {displayText.trim()}
+ >
+ );
+ }
+ return <>Confirm open>;
+}
+
+function DeceptiveTitle({ displayText }: { displayText: string }) {
+ return (
+
+
+
+ Deceptive link text was{' '}
+ {displayText.trim()} , URL was:
+
+
+ );
+}
+
+function BlockedTitle({ reason }: { reason: string }) {
+ return (
+
+
+
+ Blocked.{' '}
+ {reason}
+
+
+ );
+}
diff --git a/lib/src/components/ExternalLinkDialogHost.tsx b/lib/src/components/ExternalLinkDialogHost.tsx
new file mode 100644
index 00000000..f7266906
--- /dev/null
+++ b/lib/src/components/ExternalLinkDialogHost.tsx
@@ -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 (
+
+ );
+}
diff --git a/lib/src/components/KillConfirm.tsx b/lib/src/components/KillConfirm.tsx
index ba409d45..b3b19e2d 100644
--- a/lib/src/components/KillConfirm.tsx
+++ b/lib/src/components/KillConfirm.tsx
@@ -1,6 +1,5 @@
-import { useLayoutEffect, useState } from 'react';
import { resolvePaneElement } from '../lib/spatial-nav';
-import { Shortcut } from './design';
+import { ModalOverlay, ModalSurface, Shortcut } from './design';
export type KillExit = 'shake' | 'confirm';
@@ -21,7 +20,7 @@ export function randomKillChar(): string {
export function KillConfirmCard({ char, onCancel, exit }: { char: string; onCancel?: () => void; exit?: KillExit }) {
return (
-
+
Confirm kill
to cancel
-
+
);
}
@@ -48,44 +47,14 @@ export function KillConfirmOverlay({ confirmKill, paneElements, onCancel }: {
paneElements: Map;
onCancel: () => void;
}) {
- const exitClass = confirmKill.exit === 'confirm' ? ' kill-overlay-confirm' : '';
- const [rect, setRect] = useState<{ top: number; left: number; width: number; height: number } | null>(null);
-
- // useLayoutEffect (not useEffect) so the initial measurement + re-render happens
- // before the browser paints. Otherwise the centered-in-viewport fallback below
- // flashes for one frame before the overlay snaps to the panel.
- useLayoutEffect(() => {
- const panelEl = resolvePaneElement(paneElements.get(confirmKill.id));
- if (!panelEl) { setRect(null); return; }
-
- const update = () => {
- const r = panelEl.getBoundingClientRect();
- setRect({ top: r.top, left: r.left, width: r.width, height: r.height });
- };
-
- update();
- const ro = new ResizeObserver(update);
- ro.observe(panelEl);
- window.addEventListener('resize', update);
- return () => { ro.disconnect(); window.removeEventListener('resize', update); };
- }, [confirmKill.id, paneElements]);
-
- if (rect) {
- return (
-
-
-
- );
- }
-
+ const panelEl = resolvePaneElement(paneElements.get(confirmKill.id));
return (
-
+
-
+
);
}
-
diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx
index f96a9a8e..c105f15f 100644
--- a/lib/src/components/Wall.tsx
+++ b/lib/src/components/Wall.tsx
@@ -8,6 +8,7 @@ import {
} from 'dockview-react';
import 'dockview-react/dist/styles/dockview.css';
import { Baseboard } from './Baseboard';
+import { ExternalLinkDialogHost } from './ExternalLinkDialogHost';
import { KILL_CONFIRM_MS, KILL_SHAKE_MS, KillConfirmOverlay, randomKillChar, type ConfirmKill } from './KillConfirm';
import {
clearSessionAttention,
@@ -772,6 +773,8 @@ export function Wall({
version={paneElementsVersion}
/>
+
+
diff --git a/lib/src/components/design.tsx b/lib/src/components/design.tsx
index 7793a869..9cd64c13 100644
--- a/lib/src/components/design.tsx
+++ b/lib/src/components/design.tsx
@@ -1,6 +1,7 @@
import { clsx } from 'clsx';
import { tv, type VariantProps } from 'tailwind-variants';
-import type { HTMLAttributes, ReactNode } from 'react';
+import { forwardRef, useEffect, useLayoutEffect, useState } from 'react';
+import type { CSSProperties, HTMLAttributes, ReactNode, RefObject } from 'react';
// App-wide type scale, color strategy, and chrome conventions: see
// docs/specs/theme.md and AGENTS.md.
@@ -54,6 +55,214 @@ export const popupButton = tv({
export type PopupButtonVariants = VariantProps;
+export interface ModalRect {
+ top: number;
+ left: number;
+ width: number;
+ height: number;
+}
+
+export const modalOverlay = tv({
+ base: 'flex items-center justify-center',
+ variants: {
+ scope: {
+ viewport: 'fixed inset-0',
+ target: 'rounded',
+ },
+ backdrop: {
+ standard: 'bg-app-bg/50',
+ strong: 'bg-app-bg/55',
+ },
+ },
+ defaultVariants: { scope: 'viewport', backdrop: 'standard' },
+});
+
+export type ModalOverlayVariants = VariantProps;
+
+export const modalSurface = tv({
+ base: 'rounded-lg border border-border bg-surface-raised font-mono text-foreground shadow-lg',
+ variants: {
+ padding: {
+ compact: 'p-3',
+ default: 'p-4',
+ spacious: 'px-6 py-4',
+ },
+ align: {
+ start: 'text-left',
+ center: 'text-center',
+ },
+ elevation: {
+ dialog: 'shadow-lg',
+ modal: 'shadow-2xl',
+ },
+ },
+ defaultVariants: { padding: 'default', align: 'start', elevation: 'dialog' },
+});
+
+export type ModalSurfaceVariants = VariantProps;
+
+export const modalActionButton = tv({
+ base: 'rounded px-2 py-1.5 text-xs transition-colors focus-visible:outline focus-visible:outline-1 focus-visible:outline-focus-ring disabled:cursor-not-allowed disabled:opacity-45',
+ variants: {
+ tone: {
+ primary: 'bg-header-active-bg text-header-active-fg',
+ secondary: 'border border-border text-muted hover:bg-header-inactive-bg hover:text-foreground',
+ },
+ },
+ defaultVariants: { tone: 'secondary' },
+});
+
+export type ModalActionButtonVariants = VariantProps;
+
+export const modalIconButton = tv({
+ base: 'shrink-0 rounded p-0.5 text-muted transition-colors hover:bg-foreground/10 hover:text-foreground focus-visible:outline focus-visible:outline-1 focus-visible:outline-focus-ring',
+});
+
+export function useMeasuredElementRect(element: HTMLElement | null): ModalRect | null {
+ const [rect, setRect] = useState(null);
+
+ useLayoutEffect(() => {
+ if (!element) {
+ setRect(null);
+ return;
+ }
+
+ const update = () => {
+ const next = element.getBoundingClientRect();
+ setRect({
+ top: next.top,
+ left: next.left,
+ width: next.width,
+ height: next.height,
+ });
+ };
+
+ update();
+ const ro = new ResizeObserver(update);
+ ro.observe(element);
+ window.addEventListener('resize', update);
+ return () => {
+ ro.disconnect();
+ window.removeEventListener('resize', update);
+ };
+ }, [element]);
+
+ return rect;
+}
+
+export function ModalOverlay({
+ children,
+ targetElement,
+ zIndex = 100,
+ backdrop = 'standard',
+ className,
+ style,
+ ...props
+}: HTMLAttributes & ModalOverlayVariants & {
+ targetElement?: HTMLElement | null;
+ zIndex?: number;
+}) {
+ const rect = useMeasuredElementRect(targetElement ?? null);
+ const overlayStyle: CSSProperties = rect
+ ? {
+ position: 'fixed',
+ top: rect.top,
+ left: rect.left,
+ width: rect.width,
+ height: rect.height,
+ zIndex,
+ ...style,
+ }
+ : { zIndex, ...style };
+
+ return (
+
+ {children}
+
+ );
+}
+
+export type ModalSurfaceProps = HTMLAttributes & ModalSurfaceVariants;
+
+export const ModalSurface = forwardRef(function ModalSurface({
+ children,
+ padding,
+ align,
+ elevation,
+ className,
+ ...props
+}, ref) {
+ return (
+
+ {children}
+
+ );
+});
+
+const MODAL_FOCUSABLE_SELECTOR = [
+ 'a[href]',
+ 'button:not([disabled])',
+ 'input:not([disabled])',
+ 'select:not([disabled])',
+ 'textarea:not([disabled])',
+ '[tabindex]:not([tabindex="-1"])',
+].join(',');
+
+export function useModalFocusTrap(
+ dialogRef: RefObject,
+ {
+ initialFocusRef,
+ onEscape,
+ }: {
+ initialFocusRef?: RefObject;
+ onEscape?: () => void;
+ } = {},
+): void {
+ useEffect(() => {
+ initialFocusRef?.current?.focus();
+ }, [initialFocusRef]);
+
+ useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ const dialog = dialogRef.current;
+ if (!dialog) return;
+
+ if (event.key === 'Escape') {
+ if (onEscape) {
+ event.preventDefault();
+ event.stopPropagation();
+ onEscape();
+ }
+ return;
+ }
+
+ if (event.key !== 'Tab') return;
+
+ const focusables = Array.from(dialog.querySelectorAll(MODAL_FOCUSABLE_SELECTOR));
+ if (focusables.length === 0) return;
+
+ const currentIndex = focusables.findIndex((item) => item === document.activeElement);
+ const nextIndex = currentIndex === -1
+ ? 0
+ : (currentIndex + (event.shiftKey ? -1 : 1) + focusables.length) % focusables.length;
+
+ event.preventDefault();
+ focusables[nextIndex]?.focus();
+ };
+
+ window.addEventListener('keydown', handleKeyDown, true);
+ return () => window.removeEventListener('keydown', handleKeyDown, true);
+ }, [dialogRef, onEscape]);
+}
+
// Chrome buttons: icon-only and labeled triggers used in the standalone app
// bar, plus the Windows/Linux native-style window controls. All inherit text
// color from the surrounding chrome so they tint with the active/inactive
diff --git a/lib/src/components/wall/use-wall-keyboard.ts b/lib/src/components/wall/use-wall-keyboard.ts
index 27e80bf4..0c5de8ad 100644
--- a/lib/src/components/wall/use-wall-keyboard.ts
+++ b/lib/src/components/wall/use-wall-keyboard.ts
@@ -31,6 +31,7 @@ export function useWallKeyboard(ctx: WallKeyboardCtx): void {
if (!c.apiRef.current) return;
if (c.renamingRef.current) return;
if (handleKillConfirm(e, c)) return;
+ if (c.dialogKeyboardActiveRef.current) return;
if (handlePaneShortcuts(e, c, navHistory)) return;
handlePaneNavigation(e, c, navHistory);
};
diff --git a/lib/src/lib/external-link-confirmation.test.ts b/lib/src/lib/external-link-confirmation.test.ts
new file mode 100644
index 00000000..92ea3f56
--- /dev/null
+++ b/lib/src/lib/external-link-confirmation.test.ts
@@ -0,0 +1,49 @@
+import { describe, expect, it } from 'vitest';
+import {
+ clearExternalLinkConfirmation,
+ getExternalLinkConfirmationSnapshot,
+ requestExternalLinkConfirmation,
+ subscribeExternalLinkConfirmation,
+} from './external-link-confirmation';
+
+describe('external link confirmation store', () => {
+ it('publishes an inspected pending link and clears it', () => {
+ let updates = 0;
+ const unsubscribe = subscribeExternalLinkConfirmation(() => {
+ updates += 1;
+ });
+
+ requestExternalLinkConfirmation('vscode://file/Users/dev/project/app.ts');
+ expect(getExternalLinkConfirmationSnapshot()).toMatchObject({
+ uri: 'vscode://file/Users/dev/project/app.ts',
+ displayText: '',
+ verdict: 'match',
+ decision: {
+ status: 'openable',
+ scheme: 'vscode',
+ },
+ });
+
+ clearExternalLinkConfirmation();
+ expect(getExternalLinkConfirmationSnapshot()).toBeNull();
+ expect(updates).toBe(2);
+
+ unsubscribe();
+ });
+
+ it('classifies the link via displayText when provided', () => {
+ requestExternalLinkConfirmation('https://evil.com/phish', 'goog1e.com');
+ expect(getExternalLinkConfirmationSnapshot()).toMatchObject({
+ verdict: 'deceptive',
+ displayText: 'goog1e.com',
+ });
+ clearExternalLinkConfirmation();
+
+ requestExternalLinkConfirmation('https://ci.example.com/x', 'see the report');
+ expect(getExternalLinkConfirmationSnapshot()).toMatchObject({
+ verdict: 'plain',
+ displayText: 'see the report',
+ });
+ clearExternalLinkConfirmation();
+ });
+});
diff --git a/lib/src/lib/external-link-confirmation.ts b/lib/src/lib/external-link-confirmation.ts
new file mode 100644
index 00000000..2d715b2c
--- /dev/null
+++ b/lib/src/lib/external-link-confirmation.ts
@@ -0,0 +1,47 @@
+import {
+ classifyDisplayMatch,
+ inspectExternalUri,
+ type DisplayMatchVerdict,
+ type ExternalUriDecision,
+} from './external-links';
+
+export interface PendingExternalLink {
+ uri: string;
+ displayText: string;
+ verdict: DisplayMatchVerdict;
+ decision: ExternalUriDecision;
+}
+
+let pendingExternalLink: PendingExternalLink | null = null;
+const listeners = new Set<() => void>();
+
+export function requestExternalLinkConfirmation(uri: string, displayText: string = ''): void {
+ pendingExternalLink = {
+ uri,
+ displayText,
+ verdict: classifyDisplayMatch(uri, displayText),
+ decision: inspectExternalUri(uri),
+ };
+ emitExternalLinkConfirmationChange();
+}
+
+export function clearExternalLinkConfirmation(): void {
+ if (!pendingExternalLink) return;
+ pendingExternalLink = null;
+ emitExternalLinkConfirmationChange();
+}
+
+export function getExternalLinkConfirmationSnapshot(): PendingExternalLink | null {
+ return pendingExternalLink;
+}
+
+export function subscribeExternalLinkConfirmation(listener: () => void): () => void {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+}
+
+function emitExternalLinkConfirmationChange(): void {
+ for (const listener of listeners) listener();
+}
diff --git a/lib/src/lib/external-links.test.ts b/lib/src/lib/external-links.test.ts
new file mode 100644
index 00000000..abbc48f9
--- /dev/null
+++ b/lib/src/lib/external-links.test.ts
@@ -0,0 +1,77 @@
+import { describe, expect, it } from 'vitest';
+import { classifyDisplayMatch, inspectExternalUri, normalizeExternalUri } from './external-links';
+
+describe('normalizeExternalUri', () => {
+ it('allows absolute external URIs after inspection', () => {
+ expect(normalizeExternalUri('https://example.com/docs?q=mouse')).toBe('https://example.com/docs?q=mouse');
+ expect(normalizeExternalUri(' http://example.com/path ')).toBe('http://example.com/path');
+ expect(normalizeExternalUri('mailto:support@example.com')).toBe('mailto:support@example.com');
+ expect(normalizeExternalUri('file:///Users/dev/report.html')).toBe('file:///Users/dev/report.html');
+ expect(normalizeExternalUri('vscode://file/Users/dev/project/src/App.tsx:4:2')).toBe('vscode://file/Users/dev/project/src/App.tsx:4:2');
+ });
+
+ it('rejects browser-executable or opaque pseudo schemes', () => {
+ expect(normalizeExternalUri('javascript:alert(1)')).toBeNull();
+ expect(normalizeExternalUri('data:text/html,hello')).toBeNull();
+ expect(normalizeExternalUri('blob:https://example.com/id')).toBeNull();
+ expect(normalizeExternalUri('about:blank')).toBeNull();
+ });
+
+ it('rejects malformed or control-character-bearing input', () => {
+ expect(normalizeExternalUri('not a url')).toBeNull();
+ expect(normalizeExternalUri('https://example.com/\nnext')).toBeNull();
+ expect(normalizeExternalUri('')).toBeNull();
+ });
+
+ it('returns a displayable blocked reason', () => {
+ expect(inspectExternalUri('javascript:alert(1)')).toMatchObject({
+ status: 'blocked',
+ scheme: 'javascript',
+ displayUri: 'javascript:alert(1)',
+ reason: expect.stringContaining('javascript:'),
+ });
+ });
+});
+
+describe('classifyDisplayMatch', () => {
+ it('returns match when displayed text equals the URL', () => {
+ expect(classifyDisplayMatch('https://example.com/foo', 'https://example.com/foo')).toBe('match');
+ });
+
+ it('returns match when displayed text is empty (terminal auto-detected URL)', () => {
+ expect(classifyDisplayMatch('https://example.com/foo', '')).toBe('match');
+ expect(classifyDisplayMatch('https://example.com/foo', ' ')).toBe('match');
+ });
+
+ it('normalizes a trailing slash and case before deciding match', () => {
+ expect(classifyDisplayMatch('https://example.com/foo/', 'https://example.com/foo')).toBe('match');
+ expect(classifyDisplayMatch('HTTPS://Example.com/Foo', 'https://example.com/Foo')).toBe('match');
+ });
+
+ it('returns plain when displayed text is a human label', () => {
+ expect(classifyDisplayMatch('https://ci.example.com/x', 'see the report')).toBe('plain');
+ expect(classifyDisplayMatch('https://github.com/foo', 'Click here')).toBe('plain');
+ });
+
+ it('returns plain when the displayed URL has the same host as the actual URL', () => {
+ // Same host, different path — the label is a shorthand, not deceptive.
+ expect(classifyDisplayMatch('https://github.com/foo', 'github.com')).toBe('plain');
+ expect(classifyDisplayMatch('https://github.com/foo', 'https://github.com')).toBe('plain');
+ });
+
+ it('flags deceptive when the displayed URL targets a different host', () => {
+ expect(classifyDisplayMatch('https://evil.com/phish', 'https://goog1e.com')).toBe('deceptive');
+ expect(classifyDisplayMatch('https://evil.com/phish', 'goog1e.com')).toBe('deceptive');
+ expect(classifyDisplayMatch('https://evil.com/phish', 'https://google.com/maps')).toBe('deceptive');
+ });
+
+ it('flags subdomain mismatch as deceptive (conservative side of the false-positive line)', () => {
+ expect(classifyDisplayMatch('https://github.com/foo', 'docs.github.com')).toBe('deceptive');
+ });
+
+ it('treats label with embedded URL as plain unless the bare-domain pattern matches', () => {
+ // "Click for https://goog1e.com/free" contains a URL but is itself not URL-shaped.
+ // Conservative call: classify as plain. (We'd need to scan for embedded URLs to flag.)
+ expect(classifyDisplayMatch('https://evil.com/phish', 'Click for free money')).toBe('plain');
+ });
+});
diff --git a/lib/src/lib/external-links.ts b/lib/src/lib/external-links.ts
new file mode 100644
index 00000000..32731fcb
--- /dev/null
+++ b/lib/src/lib/external-links.ts
@@ -0,0 +1,132 @@
+const BLOCKED_EXTERNAL_URI_PROTOCOLS = new Set(['javascript:', 'data:', 'blob:', 'about:']);
+
+export type DisplayMatchVerdict = 'match' | 'plain' | 'deceptive';
+
+export type ExternalUriDecision =
+ | {
+ status: 'openable';
+ rawUri: string;
+ uri: string;
+ scheme: string;
+ displayUri: string;
+ }
+ | {
+ status: 'blocked';
+ rawUri: string;
+ scheme: string | null;
+ displayUri: string;
+ reason: string;
+ };
+
+export function inspectExternalUri(input: string): ExternalUriDecision {
+ const trimmed = input.trim();
+ if (!trimmed) {
+ return blocked(input, trimmed, null, 'No URL was provided.');
+ }
+
+ if (/[\x00-\x1f\x7f-\x9f]/.test(trimmed)) {
+ return blocked(input, trimmed, null, 'The URL contains control characters.');
+ }
+
+ try {
+ const uri = new URL(trimmed);
+ const scheme = uri.protocol.slice(0, -1);
+ if (BLOCKED_EXTERNAL_URI_PROTOCOLS.has(uri.protocol)) {
+ return blocked(input, trimmed, scheme, `${scheme}: URLs cannot be opened from terminal output.`);
+ }
+ return {
+ status: 'openable',
+ rawUri: input,
+ uri: uri.href,
+ scheme,
+ displayUri: trimmed,
+ };
+ } catch {
+ return blocked(input, trimmed, null, 'The URL is not valid.');
+ }
+}
+
+export function normalizeExternalUri(input: string): string | null {
+ const decision = inspectExternalUri(input);
+ return decision.status === 'openable' ? decision.uri : null;
+}
+
+// Three-tier classification of how the terminal-rendered link text compares to
+// the actual URL it points to. The dialog uses the verdict to pick a title,
+// gate the action, and decide what the user is really being asked.
+//
+// - match: visible text matches the URL after light normalization. Most
+// non-OSC-8 clicks land here, because xterm passes the URL itself as the
+// display text.
+// - deceptive: visible text is URL-shaped (looks like a URL or bare domain)
+// but resolves to a different host than the actual URL. The phishing shape.
+// - plain: anything else — a legitimate human label like "see the report",
+// or a sibling URL on the same host (different path/subdomain still counts
+// as plain so we don't false-positive on redirects).
+export function classifyDisplayMatch(uri: string, displayText: string): DisplayMatchVerdict {
+ const text = displayText.trim();
+ if (!text) return 'match';
+
+ if (normalizeForMatch(text) === normalizeForMatch(uri)) return 'match';
+
+ const shapedHost = extractUrlShapedHost(text);
+ if (shapedHost === null) return 'plain';
+
+ const actualHost = safeHost(uri);
+ if (actualHost === null) return 'plain';
+
+ return shapedHost === actualHost ? 'plain' : 'deceptive';
+}
+
+function normalizeForMatch(value: string): string {
+ let v = value.trim().toLowerCase();
+ // Drop a trailing slash so `https://x.com` and `https://x.com/` match.
+ if (v.endsWith('/')) v = v.slice(0, -1);
+ return v;
+}
+
+function safeHost(uri: string): string | null {
+ try {
+ return new URL(uri).host.toLowerCase();
+ } catch {
+ return null;
+ }
+}
+
+// "URL-shaped" display text: either contains `://`, or looks like a bare
+// domain (`goog1e.com`, `www.example.org/path`). Bare-domain detection is the
+// classic phishing shape — the attacker shows what looks like a domain in a
+// terminal label that actually links somewhere else.
+const BARE_DOMAIN_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+(?:[/?#].*)?$/i;
+
+function extractUrlShapedHost(text: string): string | null {
+ const t = text.trim();
+ if (t.includes('://')) {
+ try {
+ return new URL(t).host.toLowerCase();
+ } catch {
+ return null;
+ }
+ }
+ if (BARE_DOMAIN_RE.test(t)) {
+ const slash = t.search(/[/?#]/);
+ const host = slash === -1 ? t : t.slice(0, slash);
+ return host.toLowerCase();
+ }
+ return null;
+}
+
+function blocked(
+ rawUri: string,
+ displayUri: string,
+ scheme: string | null,
+ reason: string,
+): ExternalUriDecision {
+ return {
+ status: 'blocked',
+ rawUri,
+ scheme,
+ displayUri,
+ reason,
+ };
+}
diff --git a/lib/src/lib/platform/fake-adapter.ts b/lib/src/lib/platform/fake-adapter.ts
index 0753deaa..a8cfd5f3 100644
--- a/lib/src/lib/platform/fake-adapter.ts
+++ b/lib/src/lib/platform/fake-adapter.ts
@@ -1,5 +1,6 @@
import type { AlertStateDetail, PlatformAdapter, PtyInfo } from './types';
import { AlertManager, type SessionStatus } from '../alert-manager';
+import { normalizeExternalUri } from '../external-links';
import {
applyTerminalProtocolEvents,
collectTerminalSemanticEvents,
@@ -198,6 +199,11 @@ export class FakePtyAdapter implements PlatformAdapter {
async readClipboardFilePaths(): Promise { return null; }
async readClipboardImageAsFilePath(): Promise { return null; }
+ openExternal(uri: string): void {
+ const normalized = normalizeExternalUri(uri);
+ if (!normalized || typeof window === 'undefined') return;
+ window.open(normalized, '_blank', 'noopener,noreferrer');
+ }
requestInit(): void {}
onPtyList(_handler: (detail: { ptys: PtyInfo[] }) => void): void {}
diff --git a/lib/src/lib/platform/types.ts b/lib/src/lib/platform/types.ts
index 7b41c58b..ffa45d47 100644
--- a/lib/src/lib/platform/types.ts
+++ b/lib/src/lib/platform/types.ts
@@ -36,6 +36,10 @@ export interface PlatformAdapter {
// Only present on adapters with a native (non-DOM) drag-drop source. Currently inert in Tauri; see diffplug/dormouse#38 and tauri-apps/tauri#14373.
onFilesDropped?(handler: (paths: string[]) => void): () => void;
+ // Open a sanitized external URI. Implementations must revalidate because
+ // terminal output is untrusted.
+ openExternal?(uri: string): void;
+
// PTY event listeners
onPtyData(handler: (detail: { id: string; data: string }) => void): void;
offPtyData(handler: (detail: { id: string; data: string }) => void): void;
diff --git a/lib/src/lib/platform/vscode-adapter.test.ts b/lib/src/lib/platform/vscode-adapter.test.ts
index 2800c721..6260a1cf 100644
--- a/lib/src/lib/platform/vscode-adapter.test.ts
+++ b/lib/src/lib/platform/vscode-adapter.test.ts
@@ -69,6 +69,17 @@ describe('VSCodeAdapter PTY exit handling', () => {
expect(postMessage).toHaveBeenCalledWith({ type: 'pty:kill', id: 'pane-1' });
});
+ it('posts external hyperlink open requests to the extension host', () => {
+ const adapter = new VSCodeAdapter();
+
+ adapter.openExternal('https://example.com/docs');
+
+ expect(postMessage).toHaveBeenCalledWith({
+ type: 'dormouse:openExternal',
+ uri: 'https://example.com/docs',
+ });
+ });
+
it('parses replay buffers into semantic events and strips OSCs before forwarding', () => {
const adapter = new VSCodeAdapter();
const replays: Array<{ id: string; data: string }> = [];
diff --git a/lib/src/lib/platform/vscode-adapter.ts b/lib/src/lib/platform/vscode-adapter.ts
index d22ec84e..2bf4c65e 100644
--- a/lib/src/lib/platform/vscode-adapter.ts
+++ b/lib/src/lib/platform/vscode-adapter.ts
@@ -177,6 +177,10 @@ export class VSCodeAdapter implements PlatformAdapter {
);
}
+ openExternal(uri: string): void {
+ this.vscode.postMessage({ type: 'dormouse:openExternal', uri });
+ }
+
onPtyData(handler: (detail: { id: string; data: string }) => void): void {
this.dataHandlers.add(handler);
}
diff --git a/lib/src/lib/terminal-lifecycle.ts b/lib/src/lib/terminal-lifecycle.ts
index 6db3c174..53b9aa6e 100644
--- a/lib/src/lib/terminal-lifecycle.ts
+++ b/lib/src/lib/terminal-lifecycle.ts
@@ -1,6 +1,7 @@
-import { Terminal } from '@xterm/xterm';
+import { Terminal, type IBufferRange } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import { getPlatform } from './platform';
+import { requestExternalLinkConfirmation } from './external-link-confirmation';
import { attachMouseModeObserver } from './mouse-mode-observer';
import {
bumpRenderTick,
@@ -43,6 +44,28 @@ function seedProcessCwdAfterSpawn(id: string): void {
void getPlatform().getCwd(id).then((cwd) => fillTerminalProcessCwdByPtyId(id, cwd));
}
+// Reconstructs the visible text from an OSC 8 hyperlink's buffer range. xterm
+// passes the URL as the second arg to linkHandler.activate but not the rendered
+// link text; we read it ourselves so the dialog can tell the user whether the
+// label they clicked matched the URL. Wrapped lines concatenate without a
+// separator (the wrap is visual, not a semantic break).
+function readDisplayTextFromBuffer(terminal: Terminal, range: IBufferRange): string {
+ try {
+ const buffer = terminal.buffer.active;
+ let text = '';
+ for (let y = range.start.y; y <= range.end.y; y++) {
+ const line = buffer.getLine(y - 1);
+ if (!line) continue;
+ const startCol = y === range.start.y ? range.start.x - 1 : 0;
+ const endCol = y === range.end.y ? range.end.x : undefined;
+ text += line.translateToString(true, startCol, endCol);
+ }
+ return text.trim();
+ } catch {
+ return '';
+ }
+}
+
function createXtermHost(): { terminal: Terminal; fit: FitAddon; element: HTMLDivElement } {
const styles = getComputedStyle(document.body);
const editorFontSize = parseInt(styles.getPropertyValue('--vscode-editor-font-size'), 10) || 12;
@@ -55,6 +78,14 @@ function createXtermHost(): { terminal: Terminal; fit: FitAddon; element: HTMLDi
cursorBlink: true,
theme,
vtExtensions: { kittyKeyboard: true },
+ linkHandler: {
+ activate: (event, uri, range) => {
+ event.preventDefault();
+ // Closure capture: `terminal` is defined by the time a click fires.
+ requestExternalLinkConfirmation(uri, readDisplayTextFromBuffer(terminal, range));
+ },
+ allowNonHttpProtocols: true,
+ },
});
const fit = new FitAddon();
diff --git a/lib/src/lib/terminal-protocol.test.ts b/lib/src/lib/terminal-protocol.test.ts
index 3d741138..11c2acdd 100644
--- a/lib/src/lib/terminal-protocol.test.ts
+++ b/lib/src/lib/terminal-protocol.test.ts
@@ -150,6 +150,15 @@ describe('TerminalProtocolParser', () => {
expect(result.events).toEqual([]);
});
+ it('passes OSC 8 hyperlinks through to xterm for rendering', () => {
+ const parser = new TerminalProtocolParser();
+ const hyperlink = '\x1b]8;id=docs;https://example.com/docs\x1b\\docs\x1b]8;;\x1b\\';
+ const result = parser.process(`see ${hyperlink} now`);
+
+ expect(result.visibleData).toBe(`see ${hyperlink} now`);
+ expect(result.events).toEqual([]);
+ });
+
it('strips known unsupported iTerm2 and clipboard OSC sequences', () => {
const parser = new TerminalProtocolParser();
const result = parser.process('a\x1b]52;c;SGVsbG8=\x07b\x1b]50;Monaco\x07c');
diff --git a/lib/src/stories/ExternalLinkDialog.stories.tsx b/lib/src/stories/ExternalLinkDialog.stories.tsx
new file mode 100644
index 00000000..676557a5
--- /dev/null
+++ b/lib/src/stories/ExternalLinkDialog.stories.tsx
@@ -0,0 +1,124 @@
+import type { Meta, StoryObj } from '@storybook/react';
+import { ExternalLinkDialog } from '../components/ExternalLinkDialog';
+import { classifyDisplayMatch, inspectExternalUri } from '../lib/external-links';
+
+function DialogStory({ uri, displayText }: { uri: string; displayText: string }) {
+ return (
+
+
+
dev@dormouse:~/repo$ pnpm test
+
See the linked report for details.
+
+
{}}
+ onConfirm={() => {}}
+ />
+
+ );
+}
+
+const meta: Meta = {
+ title: 'Components/ExternalLinkDialog',
+ component: DialogStory,
+ argTypes: {
+ uri: { control: 'text' },
+ displayText: { control: 'text' },
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+// Match: terminal auto-detected the URL (no separate link text).
+export const Https: Story = {
+ args: {
+ uri: 'https://github.com/diffplug/dormouse/pull/72?tab=files',
+ displayText: '',
+ },
+};
+
+// Plain label: OSC 8 link with a human-readable label. Normal case.
+export const PlainLabel: Story = {
+ args: {
+ uri: 'https://ci.example.com/builds/dormouse/jobs/test/report.html',
+ displayText: 'see the test report',
+ },
+};
+
+// Deceptive: link text looks like a URL but resolves to a different host.
+export const DeceptiveDomain: Story = {
+ args: {
+ uri: 'https://evil.example.com/phish',
+ displayText: 'goog1e.com',
+ },
+};
+
+// Deceptive variant where the label is a full URL pretending to be the target.
+export const DeceptiveFullUrl: Story = {
+ args: {
+ uri: 'https://evil.example.com/phish',
+ displayText: 'https://github.com/diffplug/dormouse',
+ },
+};
+
+// Custom scheme + match (no separate link text).
+export const CustomScheme: Story = {
+ args: {
+ uri: 'vscode://file/Users/dev/project/src/App.tsx:42:7',
+ displayText: '',
+ },
+};
+
+// Custom scheme with a plain label.
+export const CustomSchemePlain: Story = {
+ args: {
+ uri: 'vscode://file/Users/dev/project/src/App.tsx:42:7',
+ displayText: 'open in editor',
+ },
+};
+
+// file:// URL — match case.
+export const FileUrl: Story = {
+ args: {
+ uri: 'file:///Users/dev/project/tmp/report.html',
+ displayText: '',
+ },
+};
+
+// mailto: with a label.
+export const MailtoPlain: Story = {
+ args: {
+ uri: 'mailto:support@example.com',
+ displayText: 'contact the team',
+ },
+};
+
+// Long URL stress test (match).
+export const VeryLongUrl: Story = {
+ args: {
+ uri: 'https://ci.example.com/builds/dormouse/kitty-keyboard/jobs/terminal-osc-8-hyperlink-confirmation/artifacts/reports/playwright/index.html?runId=2026-05-18T23%3A41%3A02.441Z&attempt=7&sha=d96cc07f9f66ff72b7f89433cf571e9a13d4c081680&path=packages%2Flib%2Fsrc%2Fcomponents%2FExternalLinkDialog.tsx&label=the-terminal-output-rendered-this-link-with-a-short-friendly-label-but-the-real-url-is-intentionally-extremely-long-to-verify-wrapping-scrolling-and-full-target-review-before-opening&token=eyJhbGciOiJub25lIiwidHlwIjoiSldUIiwiZGVtb19vbmx5Ijp0cnVlLCJwdXJwb3NlIjoic3Rvcnlib29rLWxvbmdfdXJsLXZpc3VhbC1jYXNlIn0',
+ displayText: '',
+ },
+};
+
+// Blocked URL (javascript: scheme) — match between displayed text and URL.
+export const Blocked: Story = {
+ args: {
+ uri: 'javascript:alert(document.cookie)',
+ displayText: '',
+ },
+};
+
+// Blocked URL hidden behind a plain label.
+export const BlockedWithLabel: Story = {
+ args: {
+ uri: 'javascript:alert(document.cookie)',
+ displayText: 'click for free shipping',
+ },
+};
diff --git a/standalone/src/tauri-adapter.ts b/standalone/src/tauri-adapter.ts
index b5f89160..6adc229a 100644
--- a/standalone/src/tauri-adapter.ts
+++ b/standalone/src/tauri-adapter.ts
@@ -1,7 +1,9 @@
import { invoke as rawInvoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
+import { open } from "@tauri-apps/plugin-shell";
import type { AlertStateDetail, PlatformAdapter, PtyInfo } from "dormouse-lib/lib/platform/types";
import { AlertManager, type SessionStatus } from "dormouse-lib/lib/alert-manager";
+import { normalizeExternalUri } from "dormouse-lib/lib/external-links";
import {
applyTerminalProtocolEvents,
collectTerminalSemanticEvents,
@@ -179,6 +181,14 @@ export class TauriAdapter implements PlatformAdapter {
} catch { return null; }
}
+ openExternal(uri: string): void {
+ const normalized = normalizeExternalUri(uri);
+ if (!normalized) return;
+ open(normalized).catch((err) =>
+ console.error("[tauri-adapter] openExternal failed:", err),
+ );
+ }
+
onFilesDropped(handler: (paths: string[]) => void): () => void {
this.filesDroppedHandlers.add(handler);
return () => { this.filesDroppedHandlers.delete(handler); };
diff --git a/vscode-ext/src/message-router.ts b/vscode-ext/src/message-router.ts
index 2fad7967..28f29484 100644
--- a/vscode-ext/src/message-router.ts
+++ b/vscode-ext/src/message-router.ts
@@ -7,6 +7,7 @@ import {
collectTerminalProtocolResponses,
TerminalProtocolParser,
} from '../../lib/src/lib/terminal-protocol';
+import { normalizeExternalUri } from '../../lib/src/lib/external-links';
import type { TerminalSemanticEvent } from '../../lib/src/lib/terminal-state';
import type { PersistedSession } from '../../lib/src/lib/session-types';
import type { WebviewMessage, ExtensionMessage } from './message-types';
@@ -270,6 +271,17 @@ export function attachRouter(
webview.postMessage({ type: 'clipboard:image', path: null, requestId: msg.requestId } satisfies ExtensionMessage);
});
break;
+ case 'dormouse:openExternal': {
+ const uri = normalizeExternalUri(msg.uri);
+ if (!uri) break;
+ void vscode.env.openExternal(vscode.Uri.parse(uri, true)).then(
+ (opened) => {
+ if (!opened) log.info(`[external-link] openExternal declined: ${uri}`);
+ },
+ (err) => log.info(`[external-link] openExternal failed: ${err?.message ?? err}`),
+ );
+ break;
+ }
case 'dormouse:init': {
// Webview has (re-)initialized — subscribe to live events.
// Tear down previous subscriptions first (webview was destroyed and recreated).
diff --git a/vscode-ext/src/message-types.ts b/vscode-ext/src/message-types.ts
index b49e8b1c..278f9d23 100644
--- a/vscode-ext/src/message-types.ts
+++ b/vscode-ext/src/message-types.ts
@@ -12,6 +12,7 @@ export type WebviewMessage =
| { type: 'pty:getShells'; requestId?: string }
| { type: 'clipboard:readFiles'; requestId: string }
| { type: 'clipboard:readImage'; requestId: string }
+ | { type: 'dormouse:openExternal'; uri: string }
| { type: 'dormouse:init' }
| { type: 'dormouse:saveState'; state: unknown }
| { type: 'dormouse:flushSessionSaveDone'; requestId: string }
diff --git a/website/src/data/dependencies.json b/website/src/data/dependencies.json
index 95501017..500ff0f7 100644
--- a/website/src/data/dependencies.json
+++ b/website/src/data/dependencies.json
@@ -29,14 +29,14 @@
},
{
"name": "@xterm/addon-fit",
- "version": "0.11.0",
+ "version": "0.12.0-beta.216",
"license": "MIT",
"author": "The xterm.js authors",
"homepage": "https://github.com/xtermjs/xterm.js/tree/master#readme"
},
{
"name": "@xterm/xterm",
- "version": "6.0.0",
+ "version": "6.1.0-beta.216",
"license": "MIT",
"author": "Christopher Jeffrey, SourceLair Private Company, xterm.js authors",
"homepage": "https://github.com/xtermjs/xterm.js#readme"