From 5b3b480b564d73c1dcb648c9bdb8961456ad089d Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Mon, 18 May 2026 15:29:34 -0700 Subject: [PATCH 01/17] Enable OSC 8 hyperlink activation --- docs/specs/terminal-escapes.md | 12 ++++++++--- docs/specs/transport.md | 3 ++- lib/src/lib/external-links.test.ts | 22 +++++++++++++++++++++ lib/src/lib/external-links.ts | 13 ++++++++++++ lib/src/lib/platform/fake-adapter.ts | 6 ++++++ lib/src/lib/platform/types.ts | 4 ++++ lib/src/lib/platform/vscode-adapter.test.ts | 11 +++++++++++ lib/src/lib/platform/vscode-adapter.ts | 4 ++++ lib/src/lib/terminal-lifecycle.ts | 10 ++++++++++ lib/src/lib/terminal-protocol.test.ts | 9 +++++++++ standalone/src/tauri-adapter.ts | 10 ++++++++++ vscode-ext/src/message-router.ts | 12 +++++++++++ vscode-ext/src/message-types.ts | 1 + 13 files changed, 113 insertions(+), 4 deletions(-) create mode 100644 lib/src/lib/external-links.test.ts create mode 100644 lib/src/lib/external-links.ts diff --git a/docs/specs/terminal-escapes.md b/docs/specs/terminal-escapes.md index 70e7043c..a204c023 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 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: -- `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`. @@ -40,6 +40,10 @@ The parser also classifies each PTY data chunk for activity-monitor purposes: 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. +### 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. 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. + ## Supported OSCs | Sequence | Purpose | Spec | @@ -48,6 +52,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 ; <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 and opened through MouseTerm's external-link allowlist (`http:`, `https:`, `mailto:`). | 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) | @@ -135,6 +140,7 @@ This list is non-exhaustive. Any iTerm2-compatibility OSC family that MouseTerm - 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 3c142afc..af5891fd 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) | +| `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:`. | | `mouseterm:init` | Trigger resume: get PTY list + replay data | | `mouseterm:saveState` | Frontend persisting session state | | `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 | 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 `mouseterm:init`) | diff --git a/lib/src/lib/external-links.test.ts b/lib/src/lib/external-links.test.ts new file mode 100644 index 00000000..ee175b11 --- /dev/null +++ b/lib/src/lib/external-links.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { normalizeExternalUri } from './external-links'; + +describe('normalizeExternalUri', () => { + it('allows http, https, and mailto URIs', () => { + 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'); + }); + + it('rejects non-external and scriptable URI schemes', () => { + expect(normalizeExternalUri('file:///etc/passwd')).toBeNull(); + expect(normalizeExternalUri('javascript:alert(1)')).toBeNull(); + expect(normalizeExternalUri('data:text/html,hello')).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(); + }); +}); diff --git a/lib/src/lib/external-links.ts b/lib/src/lib/external-links.ts new file mode 100644 index 00000000..ed779c09 --- /dev/null +++ b/lib/src/lib/external-links.ts @@ -0,0 +1,13 @@ +const ALLOWED_EXTERNAL_URI_PROTOCOLS = new Set(['http:', 'https:', 'mailto:']); + +export function normalizeExternalUri(input: string): string | null { + const trimmed = input.trim(); + if (!trimmed || /[\x00-\x1f\x7f-\x9f]/.test(trimmed)) return null; + + try { + const uri = new URL(trimmed); + return ALLOWED_EXTERNAL_URI_PROTOCOLS.has(uri.protocol) ? uri.href : null; + } catch { + return null; + } +} 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<string[] | null> { return null; } async readClipboardImageAsFilePath(): Promise<string | null> { 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 0568846e..4baf2ca2 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/mouseterm#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 c6271f35..a2d0e4d7 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: 'mouseterm: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 0f271875..4947999a 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: 'mouseterm: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..edcd33f2 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 { FitAddon } from '@xterm/addon-fit'; import { getPlatform } from './platform'; +import { normalizeExternalUri } from './external-links'; import { attachMouseModeObserver } from './mouse-mode-observer'; import { bumpRenderTick, @@ -55,6 +56,15 @@ function createXtermHost(): { terminal: Terminal; fit: FitAddon; element: HTMLDi cursorBlink: true, theme, vtExtensions: { kittyKeyboard: true }, + linkHandler: { + activate: (event, uri) => { + event.preventDefault(); + const normalized = normalizeExternalUri(uri); + if (!normalized) return; + getPlatform().openExternal?.(normalized); + }, + 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/standalone/src/tauri-adapter.ts b/standalone/src/tauri-adapter.ts index 34c0215f..f12037f8 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 "mouseterm-lib/lib/platform/types"; import { AlertManager, type SessionStatus } from "mouseterm-lib/lib/alert-manager"; +import { normalizeExternalUri } from "mouseterm-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 a61c01ff..09c9dce2 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 'mouseterm: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 'mouseterm: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 6d0b22c7..80e1fb88 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: 'mouseterm:openExternal'; uri: string } | { type: 'mouseterm:init' } | { type: 'mouseterm:saveState'; state: unknown } | { type: 'mouseterm:flushSessionSaveDone'; requestId: string } From e9a13d43da4bd3d5ba052e14697ea7369aca7120 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Mon, 18 May 2026 15:49:36 -0700 Subject: [PATCH 02/17] Move OSC 8 docs under supported OSCs --- docs/specs/terminal-escapes.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/specs/terminal-escapes.md b/docs/specs/terminal-escapes.md index a204c023..1159d1c5 100644 --- a/docs/specs/terminal-escapes.md +++ b/docs/specs/terminal-escapes.md @@ -40,10 +40,6 @@ The parser also classifies each PTY data chunk for activity-monitor purposes: 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. -### 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. 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. - ## Supported OSCs | Sequence | Purpose | Spec | @@ -66,6 +62,10 @@ 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. 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. + ## Supported CSI The vast majority of CSI handling is delegated to xterm.js. MouseTerm 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. From 2b7f8942ce4e5dc587548730456a46057499d3b5 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Mon, 18 May 2026 16:21:59 -0700 Subject: [PATCH 03/17] Specify OSC 8 confirmation policy --- docs/specs/terminal-escapes.md | 16 +++++++++++++--- docs/specs/transport.md | 2 +- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/docs/specs/terminal-escapes.md b/docs/specs/terminal-escapes.md index c584ab98..603fc6e5 100644 --- a/docs/specs/terminal-escapes.md +++ b/docs/specs/terminal-escapes.md @@ -25,7 +25,7 @@ State-driving and security-sensitive OSCs are parsed at the PTY data boundary in - 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, 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 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: +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 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`. @@ -48,7 +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 and opened through Dormouse's external-link allowlist (`http:`, `https:`, `mailto:`). | This spec | +| `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) | @@ -64,7 +64,17 @@ Some sequences are dual-purpose. The notification rows for `OSC 9 ; <message> ST ### 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 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. +`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 diff --git a/docs/specs/transport.md b/docs/specs/transport.md index f675f02e..980ca208 100644 --- a/docs/specs/transport.md +++ b/docs/specs/transport.md @@ -77,7 +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 an already-sanitized external URI from an OSC 8 hyperlink. Hosts must revalidate and only allow `http:`, `https:`, and `mailto:`. | +| `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) | From 9f66ff790e91b584cb21c47ea9121ea464ebfc16 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Mon, 18 May 2026 16:24:17 -0700 Subject: [PATCH 04/17] Add external link confirmation dialog --- lib/src/components/ExternalLinkDialog.tsx | 127 ++++++++++++++++++ lib/src/lib/external-links.test.ts | 20 ++- lib/src/lib/external-links.ts | 62 ++++++++- .../stories/ExternalLinkDialog.stories.tsx | 54 ++++++++ 4 files changed, 254 insertions(+), 9 deletions(-) create mode 100644 lib/src/components/ExternalLinkDialog.tsx create mode 100644 lib/src/stories/ExternalLinkDialog.stories.tsx diff --git a/lib/src/components/ExternalLinkDialog.tsx b/lib/src/components/ExternalLinkDialog.tsx new file mode 100644 index 00000000..29643046 --- /dev/null +++ b/lib/src/components/ExternalLinkDialog.tsx @@ -0,0 +1,127 @@ +import { useEffect, useRef } from 'react'; +import { XIcon } from '@phosphor-icons/react'; +import type { ExternalUriDecision } from '../lib/external-links'; + +export interface ExternalLinkDialogRequest { + uri: string; + decision: ExternalUriDecision; +} + +export function ExternalLinkDialog({ + request, + onCancel, + onConfirm, +}: { + request: ExternalLinkDialogRequest; + onCancel: () => void; + onConfirm: () => void; +}) { + const dialogRef = useRef<HTMLDivElement>(null); + const cancelRef = useRef<HTMLButtonElement>(null); + const openable = request.decision.status === 'openable'; + const blockedDecision = request.decision.status === 'blocked' ? request.decision : null; + const scheme = request.decision.scheme ?? 'invalid'; + const displayUri = request.decision.displayUri || request.uri; + + useEffect(() => { + cancelRef.current?.focus(); + }, []); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + const dialog = dialogRef.current; + if (!dialog) return; + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + onCancel(); + return; + } + if (event.key !== 'Tab') return; + + const focusables = Array.from( + dialog.querySelectorAll<HTMLElement>('button:not([disabled]), [tabindex]:not([tabindex="-1"])'), + ); + 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); + }, [onCancel]); + + return ( + <div className="fixed inset-0 z-[9999] grid place-items-center bg-app-bg/55 px-4 py-6"> + <div + ref={dialogRef} + role="dialog" + aria-modal="true" + aria-labelledby="external-link-dialog-title" + className="w-full max-w-[34rem] rounded-lg border border-border bg-surface-raised p-4 font-mono text-foreground shadow-2xl" + > + <div className="flex items-start gap-3"> + <div className="min-w-0 flex-1"> + <h2 id="external-link-dialog-title" className="text-sm font-semibold leading-5 text-foreground"> + Open URL? + </h2> + <div className="mt-1 text-xs leading-snug text-muted"> + Terminal output can hide a different target behind link text. + </div> + </div> + <button + type="button" + aria-label="Cancel" + className="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" + onClick={onCancel} + > + <XIcon size={13} weight="bold" /> + </button> + </div> + + <div className="mt-4 grid gap-2"> + <div className="flex items-center gap-2 text-xs"> + <span className="text-muted">scheme</span> + <span className="rounded border border-border bg-app-bg px-1.5 py-0.5 text-foreground"> + {scheme} + </span> + </div> + <div className="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> + + {blockedDecision && ( + <div className="mt-3 rounded border border-border bg-app-bg px-2.5 py-2 text-xs leading-snug text-muted"> + {blockedDecision.reason} + </div> + )} + + <div className="mt-4 grid grid-cols-2 gap-2 text-xs"> + <button + ref={cancelRef} + type="button" + onClick={onCancel} + className="rounded border border-border px-2 py-1.5 text-muted transition-colors hover:bg-header-inactive-bg hover:text-foreground focus-visible:outline focus-visible:outline-1 focus-visible:outline-focus-ring" + > + Cancel + </button> + <button + type="button" + onClick={onConfirm} + disabled={!openable} + className="rounded bg-header-active-bg px-2 py-1.5 text-header-active-fg transition-colors focus-visible:outline focus-visible:outline-1 focus-visible:outline-focus-ring disabled:cursor-not-allowed disabled:opacity-45" + > + Open URL + </button> + </div> + </div> + </div> + ); +} diff --git a/lib/src/lib/external-links.test.ts b/lib/src/lib/external-links.test.ts index ee175b11..0d61abba 100644 --- a/lib/src/lib/external-links.test.ts +++ b/lib/src/lib/external-links.test.ts @@ -1,17 +1,20 @@ import { describe, expect, it } from 'vitest'; -import { normalizeExternalUri } from './external-links'; +import { inspectExternalUri, normalizeExternalUri } from './external-links'; describe('normalizeExternalUri', () => { - it('allows http, https, and mailto URIs', () => { + 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 non-external and scriptable URI schemes', () => { - expect(normalizeExternalUri('file:///etc/passwd')).toBeNull(); + 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', () => { @@ -19,4 +22,13 @@ describe('normalizeExternalUri', () => { 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:'), + }); + }); }); diff --git a/lib/src/lib/external-links.ts b/lib/src/lib/external-links.ts index ed779c09..ec256a14 100644 --- a/lib/src/lib/external-links.ts +++ b/lib/src/lib/external-links.ts @@ -1,13 +1,65 @@ -const ALLOWED_EXTERNAL_URI_PROTOCOLS = new Set(['http:', 'https:', 'mailto:']); +const BLOCKED_EXTERNAL_URI_PROTOCOLS = new Set(['javascript:', 'data:', 'blob:', 'about:']); -export function normalizeExternalUri(input: string): string | null { +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 || /[\x00-\x1f\x7f-\x9f]/.test(trimmed)) return null; + 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); - return ALLOWED_EXTERNAL_URI_PROTOCOLS.has(uri.protocol) ? uri.href : null; + 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 null; + 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; +} + +function blocked( + rawUri: string, + displayUri: string, + scheme: string | null, + reason: string, +): ExternalUriDecision { + return { + status: 'blocked', + rawUri, + scheme, + displayUri, + reason, + }; +} diff --git a/lib/src/stories/ExternalLinkDialog.stories.tsx b/lib/src/stories/ExternalLinkDialog.stories.tsx new file mode 100644 index 00000000..7d0adcdb --- /dev/null +++ b/lib/src/stories/ExternalLinkDialog.stories.tsx @@ -0,0 +1,54 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { ExternalLinkDialog } from '../components/ExternalLinkDialog'; +import { inspectExternalUri } from '../lib/external-links'; + +function DialogStory({ uri }: { uri: string }) { + return ( + <div className="relative h-[360px] w-[680px] overflow-hidden rounded bg-app-bg font-mono text-terminal-fg"> + <div className="p-4 text-sm"> + <div>dev@dormouse:~/repo$ pnpm test</div> + <div className="text-muted">See the linked report for details.</div> + </div> + <ExternalLinkDialog + request={{ uri, decision: inspectExternalUri(uri) }} + onCancel={() => {}} + onConfirm={() => {}} + /> + </div> + ); +} + +const meta: Meta<typeof DialogStory> = { + title: 'Components/ExternalLinkDialog', + component: DialogStory, + argTypes: { + uri: { control: 'text' }, + }, +}; + +export default meta; +type Story = StoryObj<typeof DialogStory>; + +export const Https: Story = { + args: { + uri: 'https://github.com/diffplug/dormouse/pull/72?tab=files', + }, +}; + +export const CustomScheme: Story = { + args: { + uri: 'vscode://file/Users/dev/project/src/App.tsx:42:7', + }, +}; + +export const FileUrl: Story = { + args: { + uri: 'file:///Users/dev/project/tmp/report.html', + }, +}; + +export const Blocked: Story = { + args: { + uri: 'javascript:alert(document.cookie)', + }, +}; From d96cc0758da5866dc100053daf84eb91273a1646 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Mon, 18 May 2026 16:28:18 -0700 Subject: [PATCH 05/17] Hook OSC 8 clicks into confirmation flow --- lib/src/components/ExternalLinkDialogHost.tsx | 46 +++++++++++++++++++ lib/src/components/Wall.tsx | 3 ++ lib/src/components/wall/use-wall-keyboard.ts | 1 + .../lib/external-link-confirmation.test.ts | 31 +++++++++++++ lib/src/lib/external-link-confirmation.ts | 38 +++++++++++++++ lib/src/lib/terminal-lifecycle.ts | 6 +-- 6 files changed, 121 insertions(+), 4 deletions(-) create mode 100644 lib/src/components/ExternalLinkDialogHost.tsx create mode 100644 lib/src/lib/external-link-confirmation.test.ts create mode 100644 lib/src/lib/external-link-confirmation.ts diff --git a/lib/src/components/ExternalLinkDialogHost.tsx b/lib/src/components/ExternalLinkDialogHost.tsx new file mode 100644 index 00000000..40d3b7e1 --- /dev/null +++ b/lib/src/components/ExternalLinkDialogHost.tsx @@ -0,0 +1,46 @@ +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={pending} + onCancel={close} + onConfirm={confirm} + /> + ); +} 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} /> + <ExternalLinkDialogHost onKeyboardActiveChange={setDialogKeyboardActive} /> + </div> </DialogKeyboardContext.Provider> </FreshlySpawnedContext.Provider> 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..dc40122f --- /dev/null +++ b/lib/src/lib/external-link-confirmation.test.ts @@ -0,0 +1,31 @@ +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', + decision: { + status: 'openable', + scheme: 'vscode', + }, + }); + + clearExternalLinkConfirmation(); + expect(getExternalLinkConfirmationSnapshot()).toBeNull(); + expect(updates).toBe(2); + + unsubscribe(); + }); +}); diff --git a/lib/src/lib/external-link-confirmation.ts b/lib/src/lib/external-link-confirmation.ts new file mode 100644 index 00000000..19a7b09d --- /dev/null +++ b/lib/src/lib/external-link-confirmation.ts @@ -0,0 +1,38 @@ +import { inspectExternalUri, type ExternalUriDecision } from './external-links'; + +export interface PendingExternalLink { + uri: string; + decision: ExternalUriDecision; +} + +let pendingExternalLink: PendingExternalLink | null = null; +const listeners = new Set<() => void>(); + +export function requestExternalLinkConfirmation(uri: string): void { + pendingExternalLink = { + uri, + 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/terminal-lifecycle.ts b/lib/src/lib/terminal-lifecycle.ts index edcd33f2..13fd6636 100644 --- a/lib/src/lib/terminal-lifecycle.ts +++ b/lib/src/lib/terminal-lifecycle.ts @@ -1,7 +1,7 @@ import { Terminal } from '@xterm/xterm'; import { FitAddon } from '@xterm/addon-fit'; import { getPlatform } from './platform'; -import { normalizeExternalUri } from './external-links'; +import { requestExternalLinkConfirmation } from './external-link-confirmation'; import { attachMouseModeObserver } from './mouse-mode-observer'; import { bumpRenderTick, @@ -59,9 +59,7 @@ function createXtermHost(): { terminal: Terminal; fit: FitAddon; element: HTMLDi linkHandler: { activate: (event, uri) => { event.preventDefault(); - const normalized = normalizeExternalUri(uri); - if (!normalized) return; - getPlatform().openExternal?.(normalized); + requestExternalLinkConfirmation(uri); }, allowNonHttpProtocols: true, }, From c081680b37771090108b1085715683dadac54402 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Mon, 18 May 2026 16:40:42 -0700 Subject: [PATCH 06/17] Extract shared modal primitives --- lib/src/components/ExternalLinkDialog.tsx | 60 ++---- lib/src/components/KillConfirm.tsx | 49 +---- lib/src/components/design.tsx | 211 +++++++++++++++++++++- 3 files changed, 237 insertions(+), 83 deletions(-) diff --git a/lib/src/components/ExternalLinkDialog.tsx b/lib/src/components/ExternalLinkDialog.tsx index 29643046..9c453c2e 100644 --- a/lib/src/components/ExternalLinkDialog.tsx +++ b/lib/src/components/ExternalLinkDialog.tsx @@ -1,6 +1,13 @@ -import { useEffect, useRef } from 'react'; +import { useRef } from 'react'; import { XIcon } from '@phosphor-icons/react'; import type { ExternalUriDecision } from '../lib/external-links'; +import { + ModalOverlay, + ModalSurface, + modalActionButton, + modalIconButton, + useModalFocusTrap, +} from './design'; export interface ExternalLinkDialogRequest { uri: string; @@ -23,48 +30,17 @@ export function ExternalLinkDialog({ const scheme = request.decision.scheme ?? 'invalid'; const displayUri = request.decision.displayUri || request.uri; - useEffect(() => { - cancelRef.current?.focus(); - }, []); - - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - const dialog = dialogRef.current; - if (!dialog) return; - if (event.key === 'Escape') { - event.preventDefault(); - event.stopPropagation(); - onCancel(); - return; - } - if (event.key !== 'Tab') return; - - const focusables = Array.from( - dialog.querySelectorAll<HTMLElement>('button:not([disabled]), [tabindex]:not([tabindex="-1"])'), - ); - 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); - }, [onCancel]); + useModalFocusTrap(dialogRef, { initialFocusRef: cancelRef, onEscape: onCancel }); return ( - <div className="fixed inset-0 z-[9999] grid place-items-center bg-app-bg/55 px-4 py-6"> - <div + <ModalOverlay zIndex={9999} backdrop="strong" className="px-4 py-6"> + <ModalSurface ref={dialogRef} role="dialog" aria-modal="true" aria-labelledby="external-link-dialog-title" - className="w-full max-w-[34rem] rounded-lg border border-border bg-surface-raised p-4 font-mono text-foreground shadow-2xl" + elevation="modal" + className="w-full max-w-[34rem]" > <div className="flex items-start gap-3"> <div className="min-w-0 flex-1"> @@ -78,7 +54,7 @@ export function ExternalLinkDialog({ <button type="button" aria-label="Cancel" - className="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" + className={modalIconButton()} onClick={onCancel} > <XIcon size={13} weight="bold" /> @@ -108,7 +84,7 @@ export function ExternalLinkDialog({ ref={cancelRef} type="button" onClick={onCancel} - className="rounded border border-border px-2 py-1.5 text-muted transition-colors hover:bg-header-inactive-bg hover:text-foreground focus-visible:outline focus-visible:outline-1 focus-visible:outline-focus-ring" + className={modalActionButton({ tone: 'secondary' })} > Cancel </button> @@ -116,12 +92,12 @@ export function ExternalLinkDialog({ type="button" onClick={onConfirm} disabled={!openable} - className="rounded bg-header-active-bg px-2 py-1.5 text-header-active-fg transition-colors focus-visible:outline focus-visible:outline-1 focus-visible:outline-focus-ring disabled:cursor-not-allowed disabled:opacity-45" + className={modalActionButton({ tone: 'primary' })} > Open URL </button> </div> - </div> - </div> + </ModalSurface> + </ModalOverlay> ); } 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 ( - <div className={`bg-surface-raised border border-border px-6 py-4 rounded-lg text-center shadow-lg font-mono${exit === 'shake' ? ' motion-safe:animate-shake-x' : ''}`}> + <ModalSurface padding="spacious" align="center" className={exit === 'shake' ? 'motion-safe:animate-shake-x' : undefined}> <h2 className="text-base font-bold mb-3 text-foreground">Confirm kill</h2> <div className="bg-app-bg py-2 px-6 rounded border border-border inline-block mb-2"> <span @@ -39,7 +38,7 @@ export function KillConfirmCard({ char, onCancel, exit }: { char: string; onCanc <span className="justify-self-start group-hover:text-foreground transition-colors">to cancel</span> </button> </div> - </div> + </ModalSurface> ); } @@ -48,44 +47,14 @@ export function KillConfirmOverlay({ confirmKill, paneElements, onCancel }: { paneElements: Map<string, HTMLElement>; 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 ( - <div - style={{ position: 'fixed', top: rect.top, left: rect.left, width: rect.width, height: rect.height, zIndex: 100 }} - className={`flex items-center justify-center bg-app-bg/50 rounded${exitClass}`} - > - <KillConfirmCard char={confirmKill.char} onCancel={onCancel} exit={confirmKill.exit} /> - </div> - ); - } - + const panelEl = resolvePaneElement(paneElements.get(confirmKill.id)); return ( - <div className={`fixed inset-0 bg-app-bg/50 z-[100] flex items-center justify-center${exitClass}`}> + <ModalOverlay + targetElement={panelEl} + className={confirmKill.exit === 'confirm' ? 'kill-overlay-confirm' : undefined} + > <KillConfirmCard char={confirmKill.char} onCancel={onCancel} exit={confirmKill.exit} /> - </div> + </ModalOverlay> ); } - 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<typeof popupButton>; +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<typeof modalOverlay>; + +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<typeof modalSurface>; + +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<typeof modalActionButton>; + +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<ModalRect | null>(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<HTMLDivElement> & 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 ( + <div + className={clsx(modalOverlay({ scope: rect ? 'target' : 'viewport', backdrop }), className)} + style={overlayStyle} + {...props} + > + {children} + </div> + ); +} + +export type ModalSurfaceProps = HTMLAttributes<HTMLDivElement> & ModalSurfaceVariants; + +export const ModalSurface = forwardRef<HTMLDivElement, ModalSurfaceProps>(function ModalSurface({ + children, + padding, + align, + elevation, + className, + ...props +}, ref) { + return ( + <div + ref={ref} + className={clsx(modalSurface({ padding, align, elevation }), className)} + {...props} + > + {children} + </div> + ); +}); + +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<TDialog extends HTMLElement, TInitial extends HTMLElement>( + dialogRef: RefObject<TDialog | null>, + { + initialFocusRef, + onEscape, + }: { + initialFocusRef?: RefObject<TInitial | null>; + 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<HTMLElement>(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 From b0986fab605e74f41d000ab523bb0922f726d8bb Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Mon, 18 May 2026 17:12:28 -0700 Subject: [PATCH 07/17] Add long URL dialog story --- lib/src/stories/ExternalLinkDialog.stories.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/src/stories/ExternalLinkDialog.stories.tsx b/lib/src/stories/ExternalLinkDialog.stories.tsx index 7d0adcdb..1c41965b 100644 --- a/lib/src/stories/ExternalLinkDialog.stories.tsx +++ b/lib/src/stories/ExternalLinkDialog.stories.tsx @@ -47,6 +47,12 @@ export const FileUrl: Story = { }, }; +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', + }, +}; + export const Blocked: Story = { args: { uri: 'javascript:alert(document.cookie)', From f3c231940f7881d8687be7eb25121ae3c2aa56e8 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Mon, 18 May 2026 20:51:34 -0700 Subject: [PATCH 08/17] Replace scheme chip with human action label in link dialog The scheme metadata pill duplicated the URL prefix and read as a code field on a security dialog. Replace it with a one-line action subtitle that answers "where will this go?": browser, local file, email app, phone app, or unknown scheme handler. Blocked URLs get a prohibit icon, inline reason, and a single Close button instead of a disabled Open URL. Also flattens the nested-card borders on the URL block and the blocked-reason box, per the bg-only chrome rule in DESIGN.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- lib/src/components/ExternalLinkDialog.tsx | 147 ++++++++++++++++------ 1 file changed, 106 insertions(+), 41 deletions(-) diff --git a/lib/src/components/ExternalLinkDialog.tsx b/lib/src/components/ExternalLinkDialog.tsx index 9c453c2e..fa3b3ccd 100644 --- a/lib/src/components/ExternalLinkDialog.tsx +++ b/lib/src/components/ExternalLinkDialog.tsx @@ -1,5 +1,14 @@ import { useRef } from 'react'; -import { XIcon } from '@phosphor-icons/react'; +import { + AppWindowIcon, + ArrowSquareOutIcon, + EnvelopeIcon, + FileTextIcon, + PhoneIcon, + ProhibitIcon, + XIcon, + type Icon, +} from '@phosphor-icons/react'; import type { ExternalUriDecision } from '../lib/external-links'; import { ModalOverlay, @@ -14,6 +23,28 @@ export interface ExternalLinkDialogRequest { decision: ExternalUriDecision; } +interface SchemeAction { + Icon: Icon; + label: string; +} + +function describeOpenable(scheme: string): SchemeAction { + switch (scheme) { + case 'http': + case 'https': + return { Icon: ArrowSquareOutIcon, label: 'Opens in your browser' }; + case 'file': + return { Icon: FileTextIcon, label: 'Opens a local file' }; + case 'mailto': + return { Icon: EnvelopeIcon, label: 'Opens your email app' }; + case 'tel': + case 'sms': + return { Icon: PhoneIcon, label: 'Opens your phone app' }; + default: + return { Icon: AppWindowIcon, label: `Opens with your ${scheme}: handler` }; + } +} + export function ExternalLinkDialog({ request, onCancel, @@ -24,13 +55,18 @@ export function ExternalLinkDialog({ onConfirm: () => void; }) { const dialogRef = useRef<HTMLDivElement>(null); - const cancelRef = useRef<HTMLButtonElement>(null); - const openable = request.decision.status === 'openable'; + const cancelButtonRef = useRef<HTMLButtonElement>(null); + const closeButtonRef = useRef<HTMLButtonElement>(null); + const openableDecision = request.decision.status === 'openable' ? request.decision : null; const blockedDecision = request.decision.status === 'blocked' ? request.decision : null; - const scheme = request.decision.scheme ?? 'invalid'; + const openable = openableDecision !== null; const displayUri = request.decision.displayUri || request.uri; + const action = openableDecision ? describeOpenable(openableDecision.scheme) : null; - useModalFocusTrap(dialogRef, { initialFocusRef: cancelRef, onEscape: onCancel }); + useModalFocusTrap(dialogRef, { + initialFocusRef: openable ? cancelButtonRef : closeButtonRef, + onEscape: onCancel, + }); return ( <ModalOverlay zIndex={9999} backdrop="strong" className="px-4 py-6"> @@ -39,21 +75,48 @@ export function ExternalLinkDialog({ role="dialog" aria-modal="true" aria-labelledby="external-link-dialog-title" + aria-describedby="external-link-dialog-status" elevation="modal" className="w-full max-w-[34rem]" > <div className="flex items-start gap-3"> <div className="min-w-0 flex-1"> - <h2 id="external-link-dialog-title" className="text-sm font-semibold leading-5 text-foreground"> + <h2 + id="external-link-dialog-title" + className="text-sm font-semibold leading-5 text-foreground" + > Open URL? </h2> - <div className="mt-1 text-xs leading-snug text-muted"> - Terminal output can hide a different target behind link text. + <div id="external-link-dialog-status" className="mt-1 flex items-start gap-1.5 text-xs leading-snug"> + {action ? ( + <> + <action.Icon + size={13} + weight="regular" + className="mt-px shrink-0 text-muted" + aria-hidden + /> + <span className="text-muted">{action.label}</span> + </> + ) : ( + <> + <ProhibitIcon + size={13} + weight="bold" + className="mt-px shrink-0 text-error" + aria-hidden + /> + <span className="text-foreground"> + <span className="font-semibold">Blocked.</span>{' '} + <span className="text-muted">{blockedDecision?.reason}</span> + </span> + </> + )} </div> </div> <button type="button" - aria-label="Cancel" + aria-label="Close" className={modalIconButton()} onClick={onCancel} > @@ -61,42 +124,44 @@ export function ExternalLinkDialog({ </button> </div> - <div className="mt-4 grid gap-2"> - <div className="flex items-center gap-2 text-xs"> - <span className="text-muted">scheme</span> - <span className="rounded border border-border bg-app-bg px-1.5 py-0.5 text-foreground"> - {scheme} - </span> - </div> - <div className="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-3 max-h-40 overflow-auto whitespace-pre-wrap break-all rounded bg-app-bg px-2.5 py-2 text-sm leading-relaxed text-foreground"> + {displayUri} </div> - {blockedDecision && ( - <div className="mt-3 rounded border border-border bg-app-bg px-2.5 py-2 text-xs leading-snug text-muted"> - {blockedDecision.reason} + <p className="mt-3 text-xs leading-snug text-muted"> + Terminal output can hide a different target behind link text. + </p> + + {openable ? ( + <div className="mt-4 grid grid-cols-2 gap-2 text-xs"> + <button + ref={cancelButtonRef} + type="button" + onClick={onCancel} + className={modalActionButton({ tone: 'secondary' })} + > + Cancel + </button> + <button + type="button" + onClick={onConfirm} + className={modalActionButton({ tone: 'primary' })} + > + Open URL + </button> + </div> + ) : ( + <div className="mt-4 flex justify-end text-xs"> + <button + ref={closeButtonRef} + type="button" + onClick={onCancel} + className={`${modalActionButton({ tone: 'primary' })} min-w-[6rem]`} + > + Close + </button> </div> )} - - <div className="mt-4 grid grid-cols-2 gap-2 text-xs"> - <button - ref={cancelRef} - type="button" - onClick={onCancel} - className={modalActionButton({ tone: 'secondary' })} - > - Cancel - </button> - <button - type="button" - onClick={onConfirm} - disabled={!openable} - className={modalActionButton({ tone: 'primary' })} - > - Open URL - </button> - </div> </ModalSurface> </ModalOverlay> ); From 51c902d4af2fe3b9c12b075cf903a0e590a70ac9 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Mon, 18 May 2026 21:45:36 -0700 Subject: [PATCH 09/17] Add classifyDisplayMatch verdict for link display text Returns match / plain / deceptive based on whether the visible link text matches the URL, is a generic label, or is URL-shaped with a different host. The dialog will use this to pick a title, gate the action, and decide what the user is really being asked. Subdomain mismatch is treated as deceptive on the conservative side of the false-positive line; legit redirects between same-host pages stay plain. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- lib/src/lib/external-links.test.ts | 45 +++++++++++++++++++- lib/src/lib/external-links.ts | 67 ++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 1 deletion(-) diff --git a/lib/src/lib/external-links.test.ts b/lib/src/lib/external-links.test.ts index 0d61abba..abbc48f9 100644 --- a/lib/src/lib/external-links.test.ts +++ b/lib/src/lib/external-links.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { inspectExternalUri, normalizeExternalUri } from './external-links'; +import { classifyDisplayMatch, inspectExternalUri, normalizeExternalUri } from './external-links'; describe('normalizeExternalUri', () => { it('allows absolute external URIs after inspection', () => { @@ -32,3 +32,46 @@ describe('normalizeExternalUri', () => { }); }); }); + +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 index ec256a14..32731fcb 100644 --- a/lib/src/lib/external-links.ts +++ b/lib/src/lib/external-links.ts @@ -1,5 +1,7 @@ const BLOCKED_EXTERNAL_URI_PROTOCOLS = new Set(['javascript:', 'data:', 'blob:', 'about:']); +export type DisplayMatchVerdict = 'match' | 'plain' | 'deceptive'; + export type ExternalUriDecision = | { status: 'openable'; @@ -49,6 +51,71 @@ export function normalizeExternalUri(input: string): string | null { 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, From e4ce31f2941aa651eccf543b705ec63482a790ae Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Mon, 18 May 2026 21:48:44 -0700 Subject: [PATCH 10/17] Thread OSC 8 link display text into confirmation store xterm's linkHandler.activate gets the URL but not the rendered link text. Read it from the buffer using the activate range and pass it through requestExternalLinkConfirmation so the dialog can compare what the user clicked against what would actually open. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- .../lib/external-link-confirmation.test.ts | 18 +++++++++ lib/src/lib/external-link-confirmation.ts | 13 ++++++- lib/src/lib/terminal-lifecycle.ts | 38 +++++++++++++++---- 3 files changed, 59 insertions(+), 10 deletions(-) diff --git a/lib/src/lib/external-link-confirmation.test.ts b/lib/src/lib/external-link-confirmation.test.ts index dc40122f..92ea3f56 100644 --- a/lib/src/lib/external-link-confirmation.test.ts +++ b/lib/src/lib/external-link-confirmation.test.ts @@ -16,6 +16,8 @@ describe('external link confirmation store', () => { 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', @@ -28,4 +30,20 @@ describe('external link confirmation store', () => { 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 index 19a7b09d..2d715b2c 100644 --- a/lib/src/lib/external-link-confirmation.ts +++ b/lib/src/lib/external-link-confirmation.ts @@ -1,16 +1,25 @@ -import { inspectExternalUri, type ExternalUriDecision } from './external-links'; +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): void { +export function requestExternalLinkConfirmation(uri: string, displayText: string = ''): void { pendingExternalLink = { uri, + displayText, + verdict: classifyDisplayMatch(uri, displayText), decision: inspectExternalUri(uri), }; emitExternalLinkConfirmationChange(); diff --git a/lib/src/lib/terminal-lifecycle.ts b/lib/src/lib/terminal-lifecycle.ts index 13fd6636..004030b0 100644 --- a/lib/src/lib/terminal-lifecycle.ts +++ b/lib/src/lib/terminal-lifecycle.ts @@ -1,4 +1,4 @@ -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'; @@ -44,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; @@ -56,14 +78,14 @@ function createXtermHost(): { terminal: Terminal; fit: FitAddon; element: HTMLDi cursorBlink: true, theme, vtExtensions: { kittyKeyboard: true }, - linkHandler: { - activate: (event, uri) => { - event.preventDefault(); - requestExternalLinkConfirmation(uri); - }, - allowNonHttpProtocols: true, - }, }); + terminal.options.linkHandler = { + activate: (event, uri, range) => { + event.preventDefault(); + requestExternalLinkConfirmation(uri, readDisplayTextFromBuffer(terminal, range)); + }, + allowNonHttpProtocols: true, + }; const fit = new FitAddon(); terminal.loadAddon(fit); From cf5a5816157d8cd825cf76e7dcb76b0d1c76a780 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Mon, 18 May 2026 21:54:15 -0700 Subject: [PATCH 11/17] Set linkHandler in constructor instead of post-construction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit terminal.options.linkHandler = … blew up under the test mock, which doesn't expose an .options object. Inline the handler in the constructor options instead. The closure capture of `terminal` is safe because the callback only fires on user click, well after construction. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- lib/src/lib/terminal-lifecycle.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/lib/src/lib/terminal-lifecycle.ts b/lib/src/lib/terminal-lifecycle.ts index 004030b0..53b9aa6e 100644 --- a/lib/src/lib/terminal-lifecycle.ts +++ b/lib/src/lib/terminal-lifecycle.ts @@ -78,14 +78,15 @@ function createXtermHost(): { terminal: Terminal; fit: FitAddon; element: HTMLDi cursorBlink: true, theme, vtExtensions: { kittyKeyboard: true }, - }); - terminal.options.linkHandler = { - activate: (event, uri, range) => { - event.preventDefault(); - requestExternalLinkConfirmation(uri, readDisplayTextFromBuffer(terminal, range)); + 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, }, - allowNonHttpProtocols: true, - }; + }); const fit = new FitAddon(); terminal.loadAddon(fit); From f2285faf3f3fe7cd6ef1306ea6897fe742ce2e74 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Mon, 18 May 2026 21:54:30 -0700 Subject: [PATCH 12/17] Render verdict-aware title and copy action in link dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dialog now answers three questions in its title bar: - match: "Open <noun>?" where <noun> follows the URL scheme (URL, file, email, phone app, SMS app, or "custom protocol <prefix>" for unknown schemes). - plain: 'Open <noun>: "<link text>"?' — title carries the human label that was actually clicked. - deceptive: 'Deceptive link text was "<label>", URL was:' with a red WarningOctagon icon. The Open action is replaced with "Copy deceptive URL to clipboard" so the user has to paste manually if they really want to follow the link. The merged title also subsumes the previous "Opens in your browser" subtitle and the generic "terminal output can hide..." footer; per-link verdict carries that signal now. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- lib/src/components/ExternalLinkDialog.tsx | 228 +++++++++++------- lib/src/components/ExternalLinkDialogHost.tsx | 7 +- .../stories/ExternalLinkDialog.stories.tsx | 70 +++++- 3 files changed, 214 insertions(+), 91 deletions(-) diff --git a/lib/src/components/ExternalLinkDialog.tsx b/lib/src/components/ExternalLinkDialog.tsx index fa3b3ccd..e4915015 100644 --- a/lib/src/components/ExternalLinkDialog.tsx +++ b/lib/src/components/ExternalLinkDialog.tsx @@ -1,15 +1,6 @@ import { useRef } from 'react'; -import { - AppWindowIcon, - ArrowSquareOutIcon, - EnvelopeIcon, - FileTextIcon, - PhoneIcon, - ProhibitIcon, - XIcon, - type Icon, -} from '@phosphor-icons/react'; -import type { ExternalUriDecision } from '../lib/external-links'; +import { ProhibitIcon, WarningOctagonIcon, XIcon } from '@phosphor-icons/react'; +import type { DisplayMatchVerdict, ExternalUriDecision } from '../lib/external-links'; import { ModalOverlay, ModalSurface, @@ -20,31 +11,48 @@ import { export interface ExternalLinkDialogRequest { uri: string; + displayText: string; + verdict: DisplayMatchVerdict; decision: ExternalUriDecision; } -interface SchemeAction { - Icon: Icon; - label: string; +interface OpenNoun { + // The noun phrase that follows "Open " in titles and buttons (e.g. "URL", + // "file"). For custom protocols this is JSX so we can render the scheme + // prefix as inline code. + title: React.ReactNode; + // The button label noun. May differ from the title noun for compactness + // (e.g. custom protocol uses just the prefix without "custom protocol"). + button: React.ReactNode; } -function describeOpenable(scheme: string): SchemeAction { +function pickOpenNoun(scheme: string, uri: string): OpenNoun { switch (scheme) { case 'http': case 'https': - return { Icon: ArrowSquareOutIcon, label: 'Opens in your browser' }; + return { title: 'URL', button: 'URL' }; case 'file': - return { Icon: FileTextIcon, label: 'Opens a local file' }; + return { title: 'file', button: 'file' }; case 'mailto': - return { Icon: EnvelopeIcon, label: 'Opens your email app' }; + return { title: 'email', button: 'email' }; case 'tel': + return { title: 'phone app', button: 'phone app' }; case 'sms': - return { Icon: PhoneIcon, label: 'Opens your phone app' }; - default: - return { Icon: AppWindowIcon, label: `Opens with your ${scheme}: handler` }; + return { title: 'SMS app', button: 'SMS app' }; + default: { + const prefix = schemePrefix(scheme, uri); + return { + title: <>custom protocol <code className="font-mono">{prefix}</code></>, + button: <code className="font-mono">{prefix}</code>, + }; + } } } +function schemePrefix(scheme: string, uri: string): string { + return uri.slice(scheme.length + 1).startsWith('//') ? `${scheme}://` : `${scheme}:`; +} + export function ExternalLinkDialog({ request, onCancel, @@ -55,19 +63,28 @@ export function ExternalLinkDialog({ onConfirm: () => void; }) { const dialogRef = useRef<HTMLDivElement>(null); - const cancelButtonRef = useRef<HTMLButtonElement>(null); - const closeButtonRef = useRef<HTMLButtonElement>(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 openable = openableDecision !== null; const displayUri = request.decision.displayUri || request.uri; - const action = openableDecision ? describeOpenable(openableDecision.scheme) : null; + const verdict = request.verdict; + const isDeceptive = verdict === 'deceptive'; + const noun = openableDecision ? pickOpenNoun(openableDecision.scheme, openableDecision.uri) : null; useModalFocusTrap(dialogRef, { - initialFocusRef: openable ? cancelButtonRef : closeButtonRef, + // 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 @@ -75,45 +92,20 @@ export function ExternalLinkDialog({ role="dialog" aria-modal="true" aria-labelledby="external-link-dialog-title" - aria-describedby="external-link-dialog-status" elevation="modal" className="w-full max-w-[34rem]" > <div className="flex items-start gap-3"> - <div className="min-w-0 flex-1"> - <h2 - id="external-link-dialog-title" - className="text-sm font-semibold leading-5 text-foreground" - > - Open URL? - </h2> - <div id="external-link-dialog-status" className="mt-1 flex items-start gap-1.5 text-xs leading-snug"> - {action ? ( - <> - <action.Icon - size={13} - weight="regular" - className="mt-px shrink-0 text-muted" - aria-hidden - /> - <span className="text-muted">{action.label}</span> - </> - ) : ( - <> - <ProhibitIcon - size={13} - weight="bold" - className="mt-px shrink-0 text-error" - aria-hidden - /> - <span className="text-foreground"> - <span className="font-semibold">Blocked.</span>{' '} - <span className="text-muted">{blockedDecision?.reason}</span> - </span> - </> - )} - </div> - </div> + <h2 + id="external-link-dialog-title" + className="min-w-0 flex-1 text-sm font-semibold leading-5 text-foreground" + > + {isDeceptive ? ( + <DeceptiveTitle displayText={request.displayText} /> + ) : ( + <OpenTitle noun={noun?.title ?? 'URL'} verdict={verdict} displayText={request.displayText} /> + )} + </h2> <button type="button" aria-label="Close" @@ -128,41 +120,103 @@ export function ExternalLinkDialog({ {displayUri} </div> - <p className="mt-3 text-xs leading-snug text-muted"> - Terminal output can hide a different target behind link text. - </p> - - {openable ? ( - <div className="mt-4 grid grid-cols-2 gap-2 text-xs"> - <button - ref={cancelButtonRef} - type="button" - onClick={onCancel} - className={modalActionButton({ tone: 'secondary' })} - > - Cancel - </button> - <button - type="button" - onClick={onConfirm} - className={modalActionButton({ tone: 'primary' })} - > - Open URL - </button> + {blockedDecision && !isDeceptive && ( + <div className="mt-3 flex items-start gap-1.5 text-xs leading-snug"> + <ProhibitIcon size={13} weight="bold" className="mt-px shrink-0 text-error" aria-hidden /> + <span className="text-foreground"> + <span className="font-semibold">Blocked.</span>{' '} + <span className="text-muted">{blockedDecision.reason}</span> + </span> </div> - ) : ( - <div className="mt-4 flex justify-end text-xs"> + )} + + <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 '}{noun?.button ?? 'URL'} + </button> + </> + ) : ( <button - ref={closeButtonRef} + ref={secondaryButtonRef} type="button" onClick={onCancel} className={`${modalActionButton({ tone: 'primary' })} min-w-[6rem]`} > Close </button> - </div> - )} + )} + </div> </ModalSurface> </ModalOverlay> ); } + +function OpenTitle({ + noun, + verdict, + displayText, +}: { + noun: React.ReactNode; + verdict: DisplayMatchVerdict; + displayText: string; +}) { + if (verdict === 'plain' && displayText.trim()) { + return ( + <> + Open {noun}: <span className="text-muted">"{displayText.trim()}"</span>? + </> + ); + } + return <>Open {noun}?</>; +} + +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="text-muted">"{displayText.trim()}"</span>, URL was: + </span> + </span> + ); +} diff --git a/lib/src/components/ExternalLinkDialogHost.tsx b/lib/src/components/ExternalLinkDialogHost.tsx index 40d3b7e1..f7266906 100644 --- a/lib/src/components/ExternalLinkDialogHost.tsx +++ b/lib/src/components/ExternalLinkDialogHost.tsx @@ -38,7 +38,12 @@ export function ExternalLinkDialogHost({ return ( <ExternalLinkDialog - request={pending} + request={{ + uri: pending.uri, + displayText: pending.displayText, + verdict: pending.verdict, + decision: pending.decision, + }} onCancel={close} onConfirm={confirm} /> diff --git a/lib/src/stories/ExternalLinkDialog.stories.tsx b/lib/src/stories/ExternalLinkDialog.stories.tsx index 1c41965b..676557a5 100644 --- a/lib/src/stories/ExternalLinkDialog.stories.tsx +++ b/lib/src/stories/ExternalLinkDialog.stories.tsx @@ -1,8 +1,8 @@ import type { Meta, StoryObj } from '@storybook/react'; import { ExternalLinkDialog } from '../components/ExternalLinkDialog'; -import { inspectExternalUri } from '../lib/external-links'; +import { classifyDisplayMatch, inspectExternalUri } from '../lib/external-links'; -function DialogStory({ uri }: { uri: string }) { +function DialogStory({ uri, displayText }: { uri: string; displayText: string }) { return ( <div className="relative h-[360px] w-[680px] overflow-hidden rounded bg-app-bg font-mono text-terminal-fg"> <div className="p-4 text-sm"> @@ -10,7 +10,12 @@ function DialogStory({ uri }: { uri: string }) { <div className="text-muted">See the linked report for details.</div> </div> <ExternalLinkDialog - request={{ uri, decision: inspectExternalUri(uri) }} + request={{ + uri, + displayText, + verdict: classifyDisplayMatch(uri, displayText), + decision: inspectExternalUri(uri), + }} onCancel={() => {}} onConfirm={() => {}} /> @@ -23,38 +28,97 @@ const meta: Meta<typeof DialogStory> = { component: DialogStory, argTypes: { uri: { control: 'text' }, + displayText: { control: 'text' }, }, }; export default meta; type Story = StoryObj<typeof DialogStory>; +// 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', }, }; From 4e31e5b0ab2a7272b0c0981ed0b392e559eb9e51 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Mon, 18 May 2026 22:02:35 -0700 Subject: [PATCH 13/17] Hoist blocked verdict into the link dialog title Mirrors the deceptive-title pattern: the Prohibit icon + Blocked message become the title bar, instead of "Open URL?" plus a separate Blocked row below. One verdict, one place to read it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- lib/src/components/ExternalLinkDialog.tsx | 24 +++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/lib/src/components/ExternalLinkDialog.tsx b/lib/src/components/ExternalLinkDialog.tsx index e4915015..e16179c3 100644 --- a/lib/src/components/ExternalLinkDialog.tsx +++ b/lib/src/components/ExternalLinkDialog.tsx @@ -102,6 +102,8 @@ export function ExternalLinkDialog({ > {isDeceptive ? ( <DeceptiveTitle displayText={request.displayText} /> + ) : blockedDecision ? ( + <BlockedTitle reason={blockedDecision.reason} /> ) : ( <OpenTitle noun={noun?.title ?? 'URL'} verdict={verdict} displayText={request.displayText} /> )} @@ -120,16 +122,6 @@ export function ExternalLinkDialog({ {displayUri} </div> - {blockedDecision && !isDeceptive && ( - <div className="mt-3 flex items-start gap-1.5 text-xs leading-snug"> - <ProhibitIcon size={13} weight="bold" className="mt-px shrink-0 text-error" aria-hidden /> - <span className="text-foreground"> - <span className="font-semibold">Blocked.</span>{' '} - <span className="text-muted">{blockedDecision.reason}</span> - </span> - </div> - )} - <div className="mt-4 flex justify-end gap-2 text-xs"> {isDeceptive ? ( <> @@ -220,3 +212,15 @@ function DeceptiveTitle({ displayText }: { displayText: string }) { </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> + ); +} From 0783948b669c28aae1b9c2430751b7f45e7de568 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Mon, 18 May 2026 22:27:26 -0700 Subject: [PATCH 14/17] Restore bordered nested box around the URL in link dialog DESIGN.md disallows nested cards, but in this dialog the framed URL block IS the artifact the user is being asked to scrutinize, and a bordered box reads better than the bare bg-shift. Documented the exception inline so the next pass doesn't re-flatten it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- lib/src/components/ExternalLinkDialog.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/src/components/ExternalLinkDialog.tsx b/lib/src/components/ExternalLinkDialog.tsx index e16179c3..ed28fe7e 100644 --- a/lib/src/components/ExternalLinkDialog.tsx +++ b/lib/src/components/ExternalLinkDialog.tsx @@ -118,7 +118,11 @@ export function ExternalLinkDialog({ </button> </div> - <div className="mt-3 max-h-40 overflow-auto whitespace-pre-wrap break-all rounded bg-app-bg px-2.5 py-2 text-sm leading-relaxed text-foreground"> + {/* 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> From c08dd0d7670a087923e3c68767ad31e31db00184 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Mon, 18 May 2026 22:42:56 -0700 Subject: [PATCH 15/17] Bold inline values in link dialog title, drop the quotes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Titles no longer carry blanket font-semibold; the bold weight is reserved for the inline value being called out (the link label or the deceptive display text). Surrounding sentence stays regular. Replaces the previous pattern of wrapping values in "..." with text-muted color — quotes plus muting made the extracted value recede instead of pop. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- lib/src/components/ExternalLinkDialog.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/src/components/ExternalLinkDialog.tsx b/lib/src/components/ExternalLinkDialog.tsx index ed28fe7e..3183a955 100644 --- a/lib/src/components/ExternalLinkDialog.tsx +++ b/lib/src/components/ExternalLinkDialog.tsx @@ -98,7 +98,7 @@ export function ExternalLinkDialog({ <div className="flex items-start gap-3"> <h2 id="external-link-dialog-title" - className="min-w-0 flex-1 text-sm font-semibold leading-5 text-foreground" + className="min-w-0 flex-1 text-sm leading-5 text-foreground" > {isDeceptive ? ( <DeceptiveTitle displayText={request.displayText} /> @@ -193,7 +193,7 @@ function OpenTitle({ if (verdict === 'plain' && displayText.trim()) { return ( <> - Open {noun}: <span className="text-muted">"{displayText.trim()}"</span>? + Open {noun}: <span className="font-semibold">{displayText.trim()}</span>? </> ); } @@ -211,7 +211,7 @@ function DeceptiveTitle({ displayText }: { displayText: string }) { /> <span className="leading-snug"> Deceptive link text was{' '} - <span className="text-muted">"{displayText.trim()}"</span>, URL was: + <span className="font-semibold">{displayText.trim()}</span>, URL was: </span> </span> ); From 69d038becf0fb460afb0acdad20e64ca0b53a3d3 Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Mon, 18 May 2026 23:02:35 -0700 Subject: [PATCH 16/17] Drop custom-protocol prefix from link dialog title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Title for unknown schemes is now just "Open URL?" (or "Open URL: <label>?") — the scheme prefix lives in the URL block and the "Open vscode://" button, so duplicating it in the title bar was noise. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- lib/src/components/ExternalLinkDialog.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/src/components/ExternalLinkDialog.tsx b/lib/src/components/ExternalLinkDialog.tsx index 3183a955..ecdf6a12 100644 --- a/lib/src/components/ExternalLinkDialog.tsx +++ b/lib/src/components/ExternalLinkDialog.tsx @@ -40,9 +40,11 @@ function pickOpenNoun(scheme: string, uri: string): OpenNoun { case 'sms': return { title: 'SMS app', button: 'SMS app' }; default: { + // Title stays generic ("URL"); the scheme prefix lives in the URL block + // and the button ("Open vscode://"). No need to duplicate it up top. const prefix = schemePrefix(scheme, uri); return { - title: <>custom protocol <code className="font-mono">{prefix}</code></>, + title: 'URL', button: <code className="font-mono">{prefix}</code>, }; } From ed34c737fddce87fdb0afdd824194b72d4c2025e Mon Sep 17 00:00:00 2001 From: Ned Twigg <ned.twigg@diffplug.com> Date: Mon, 18 May 2026 23:08:16 -0700 Subject: [PATCH 17/17] Use "Confirm open" as link dialog title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Title is now a header ("Confirm open" or "Confirm open: <label>") instead of a question ("Open URL?"). The scheme noun only lives in the button now — title doesn't vary by scheme, which simplifies the mental model: the title says "you're being asked to confirm", the URL block shows what, the button names the action. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- lib/src/components/ExternalLinkDialog.tsx | 50 ++++++++--------------- 1 file changed, 18 insertions(+), 32 deletions(-) diff --git a/lib/src/components/ExternalLinkDialog.tsx b/lib/src/components/ExternalLinkDialog.tsx index ecdf6a12..c90cf3e6 100644 --- a/lib/src/components/ExternalLinkDialog.tsx +++ b/lib/src/components/ExternalLinkDialog.tsx @@ -16,38 +16,24 @@ export interface ExternalLinkDialogRequest { decision: ExternalUriDecision; } -interface OpenNoun { - // The noun phrase that follows "Open " in titles and buttons (e.g. "URL", - // "file"). For custom protocols this is JSX so we can render the scheme - // prefix as inline code. - title: React.ReactNode; - // The button label noun. May differ from the title noun for compactness - // (e.g. custom protocol uses just the prefix without "custom protocol"). - button: React.ReactNode; -} - -function pickOpenNoun(scheme: string, uri: string): OpenNoun { +// "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 { title: 'URL', button: 'URL' }; + return 'URL'; case 'file': - return { title: 'file', button: 'file' }; + return 'file'; case 'mailto': - return { title: 'email', button: 'email' }; + return 'email'; case 'tel': - return { title: 'phone app', button: 'phone app' }; + return 'phone app'; case 'sms': - return { title: 'SMS app', button: 'SMS app' }; - default: { - // Title stays generic ("URL"); the scheme prefix lives in the URL block - // and the button ("Open vscode://"). No need to duplicate it up top. - const prefix = schemePrefix(scheme, uri); - return { - title: 'URL', - button: <code className="font-mono">{prefix}</code>, - }; - } + return 'SMS app'; + default: + return <code className="font-mono">{schemePrefix(scheme, uri)}</code>; } } @@ -73,7 +59,9 @@ export function ExternalLinkDialog({ const displayUri = request.decision.displayUri || request.uri; const verdict = request.verdict; const isDeceptive = verdict === 'deceptive'; - const noun = openableDecision ? pickOpenNoun(openableDecision.scheme, openableDecision.uri) : null; + const buttonNoun = openableDecision + ? pickOpenButtonNoun(openableDecision.scheme, openableDecision.uri) + : 'URL'; useModalFocusTrap(dialogRef, { // Deceptive case: focus the copy action so a default Enter doesn't dismiss @@ -107,7 +95,7 @@ export function ExternalLinkDialog({ ) : blockedDecision ? ( <BlockedTitle reason={blockedDecision.reason} /> ) : ( - <OpenTitle noun={noun?.title ?? 'URL'} verdict={verdict} displayText={request.displayText} /> + <OpenTitle verdict={verdict} displayText={request.displayText} /> )} </h2> <button @@ -164,7 +152,7 @@ export function ExternalLinkDialog({ onClick={onConfirm} className={`${modalActionButton({ tone: 'primary' })} min-w-[5rem]`} > - {'Open '}{noun?.button ?? 'URL'} + {'Open '}{buttonNoun} </button> </> ) : ( @@ -184,22 +172,20 @@ export function ExternalLinkDialog({ } function OpenTitle({ - noun, verdict, displayText, }: { - noun: React.ReactNode; verdict: DisplayMatchVerdict; displayText: string; }) { if (verdict === 'plain' && displayText.trim()) { return ( <> - Open {noun}: <span className="font-semibold">{displayText.trim()}</span>? + Confirm open: <span className="font-semibold">{displayText.trim()}</span> </> ); } - return <>Open {noun}?</>; + return <>Confirm open</>; } function DeceptiveTitle({ displayText }: { displayText: string }) {