From 4667628d3854bcbb7271a4d69aead35619448bb9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 08:06:40 +0000 Subject: [PATCH 1/5] Initial plan From abb7e4ceabf1669a8b4024dbfda78215bc0a5f39 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 08:15:11 +0000 Subject: [PATCH 2/5] Add external URI opener support for Claude Code VSCode extension - Implement registerExternalUriOpener to intercept vscode.env.openExternal() calls - Add pattern matching for Plannotator URLs (URLs containing "plannotator") - Update mock types to include ExternalUriOpener interfaces - Add comprehensive tests for external URI opener functionality - Update README with documentation for both terminal and extension modes - Add package-lock.json to .gitignore Co-authored-by: 7tg <20913374+7tg@users.noreply.github.com> --- .gitignore | 1 + README.md | 16 +++++++++-- mocks/vscode.ts | 33 ++++++++++++++++++++-- src/extension.test.ts | 64 +++++++++++++++++++++++++++++++++++++++++-- src/extension.ts | 40 +++++++++++++++++++++++++++ 5 files changed, 147 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index 1b5bada..2b798cf 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ dist/ *.vsix bun.lockb .vscode-test/ +package-lock.json diff --git a/README.md b/README.md index 052bdbe..f50c1e0 100644 --- a/README.md +++ b/README.md @@ -15,17 +15,29 @@ Opens [Plannotator](https://github.com/backnotprop/plannotator) plan reviews ins - Persists your Plannotator settings (identity, permissions, editor preferences) across sessions - Auto-closes the panel when you approve or send feedback on a plan - Works with Claude Code running in VS Code's integrated terminal +- Works with Claude Code as a VS Code extension - Configurable via VS Code settings - Manual URL opening via command palette ## How It Works -When Plannotator opens a browser to show a plan review, this extension intercepts the request and opens it in a VS Code panel instead: +When Plannotator opens a browser to show a plan review, this extension intercepts the request and opens it in a VS Code panel instead. + +### For Claude Code in Integrated Terminal: 1. The extension injects a `PLANNOTATOR_BROWSER` environment variable into integrated terminals 2. When Plannotator opens a URL, the bundled router script sends it to the extension via a local HTTP server 3. The extension opens the URL in a custom WebviewPanel with an embedded iframe -4. A local reverse proxy handles cookie persistence (VS Code webview iframes don't support cookies natively) — settings are stored in VS Code's global state and restored transparently + +### For Claude Code as VS Code Extension: + +1. The extension registers an external URI opener for HTTP/HTTPS URLs +2. When Claude Code (or any extension) tries to open a Plannotator URL via `vscode.env.openExternal()`, the opener intercepts it +3. If the URL contains "plannotator", it opens in a VS Code panel instead of an external browser + +### Cookie Persistence: + +A local reverse proxy handles cookie persistence (VS Code webview iframes don't support cookies natively) — settings are stored in VS Code's global state and restored transparently across sessions. ## Requirements diff --git a/mocks/vscode.ts b/mocks/vscode.ts index 90f5923..08360cc 100644 --- a/mocks/vscode.ts +++ b/mocks/vscode.ts @@ -5,8 +5,19 @@ export interface UriHandler { handleUri(uri: Uri): ProviderResult; } +export interface ExternalUriOpener { + canOpenExternalUri?(uri: Uri): number | undefined; + openExternalUri(uri: Uri): void | Promise; +} + +export interface ExternalUriOpenerMetadata { + schemes: string[]; + label: string; +} + // eslint-disable-next-line @typescript-eslint/no-explicit-any -export type ProviderResult = T | undefined | null | Thenable; +export type ProviderResult = T | undefined | null | Promise; +export type Thenable = Promise; export interface ExtensionContext { subscriptions: { dispose(): void }[]; @@ -19,7 +30,7 @@ export interface ExtensionContext { }; globalState: { get(key: string, defaultValue?: T): T | undefined; - update(key: string, value: unknown): Thenable; + update(key: string, value: unknown): Promise; }; } @@ -49,7 +60,7 @@ export class Uri { } static parse(value: string): Uri { - const parsed = new globalThis.URL(value); + const parsed = new URL(value); return new Uri( parsed.protocol.replace(":", ""), parsed.host, @@ -58,6 +69,15 @@ export class Uri { parsed.hash.replace("#", ""), ); } + + toString(): string { + let result = `${this.scheme}://`; + if (this.authority) result += this.authority; + result += this.path; + if (this.query) result += `?${this.query}`; + if (this.fragment) result += `#${this.fragment}`; + return result; + } } export const commands = { @@ -85,6 +105,13 @@ export const window = { registerUriHandler(_handler: unknown) { return { dispose() {} }; }, + registerExternalUriOpener( + _id: string, + _opener: ExternalUriOpener, + _metadata: ExternalUriOpenerMetadata, + ) { + return { dispose() {} }; + }, async showInformationMessage(_message: string) { return undefined; }, diff --git a/src/extension.test.ts b/src/extension.test.ts index b609bdf..90b0f18 100644 --- a/src/extension.test.ts +++ b/src/extension.test.ts @@ -74,7 +74,67 @@ describe("activate", () => { it("pushes disposables to context.subscriptions", async () => { await activate(context as unknown as vscode.ExtensionContext); - // Cookie proxy + IPC server + command = at least 3 subscriptions - expect(context.subscriptions.length).toBeGreaterThanOrEqual(3); + // Cookie proxy + IPC server + command + external URI opener = at least 4 subscriptions + expect(context.subscriptions.length).toBeGreaterThanOrEqual(4); + }); + + it("registers external URI opener for plannotator URLs", async () => { + const windowWithOpener = vscode.window as typeof vscode.window & { + registerExternalUriOpener?: (...args: any[]) => any; + }; + const spy = spyOn(windowWithOpener, "registerExternalUriOpener" as any); + spies.push(spy); + + await activate(context as unknown as vscode.ExtensionContext); + + expect(spy).toHaveBeenCalledWith( + "plannotator-webview.opener", + expect.objectContaining({ + canOpenExternalUri: expect.any(Function), + openExternalUri: expect.any(Function), + }), + expect.objectContaining({ + schemes: ["http", "https"], + label: "Open Plannotator in VS Code", + }), + ); + }); + + it("external URI opener returns priority for plannotator URLs", async () => { + let capturedOpener: any; + const windowWithOpener = vscode.window as typeof vscode.window & { + registerExternalUriOpener?: (...args: any[]) => any; + }; + const spy = spyOn(windowWithOpener, "registerExternalUriOpener" as any); + spy.mockImplementation((_id: string, opener: any) => { + capturedOpener = opener; + return { dispose() {} }; + }); + spies.push(spy); + + await activate(context as unknown as vscode.ExtensionContext); + + const testUri = vscode.Uri.parse("http://localhost:3000/plannotator"); + const priority = capturedOpener.canOpenExternalUri(testUri); + expect(priority).toBe(2); + }); + + it("external URI opener returns undefined for non-plannotator URLs", async () => { + let capturedOpener: any; + const windowWithOpener = vscode.window as typeof vscode.window & { + registerExternalUriOpener?: (...args: any[]) => any; + }; + const spy = spyOn(windowWithOpener, "registerExternalUriOpener" as any); + spy.mockImplementation((_id: string, opener: any) => { + capturedOpener = opener; + return { dispose() {} }; + }); + spies.push(spy); + + await activate(context as unknown as vscode.ExtensionContext); + + const testUri = vscode.Uri.parse("http://example.com"); + const priority = capturedOpener.canOpenExternalUri(testUri); + expect(priority).toBeUndefined(); }); }); diff --git a/src/extension.ts b/src/extension.ts index 2f34711..91fd64d 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -77,6 +77,46 @@ export async function activate(context: vscode.ExtensionContext): Promise }, ); context.subscriptions.push(openCommand); + + // Register external URI opener to intercept URLs from Claude Code VSCode extension + // This uses optional chaining because the API might not be available in older VSCode versions + // Note: This API was added in VSCode 1.54 but may not be in all type definitions + const windowWithOpener = vscode.window as typeof vscode.window & { + registerExternalUriOpener?: ( + id: string, + opener: { + canOpenExternalUri(uri: vscode.Uri): number | undefined; + openExternalUri(uri: vscode.Uri): void; + }, + metadata: { schemes: string[]; label: string }, + ) => vscode.Disposable; + }; + + const externalOpener = windowWithOpener.registerExternalUriOpener?.( + "plannotator-webview.opener", + { + canOpenExternalUri(uri: vscode.Uri): number | undefined { + const urlString = uri.toString(); + // Check if URL contains "plannotator" substring (matches both localhost and remote URLs) + if (urlString.includes("plannotator")) { + return 2; // Priority to handle this URL + } + return undefined; // Don't handle this URL + }, + openExternalUri(uri: vscode.Uri): void { + const urlString = uri.toString(); + log.info(`[external-opener] Opening URL: ${urlString}`); + openInPanel(urlString); + }, + }, + { + schemes: ["http", "https"], + label: "Open Plannotator in VS Code", + }, + ); + if (externalOpener) { + context.subscriptions.push(externalOpener); + } } export function deactivate(): void {} From 973ad4adac2f89f7696239e6615c454c0393b8be Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 08:16:38 +0000 Subject: [PATCH 3/5] Address code review feedback - Add detailed comments explaining URL matching strategy and priority value - Restore globalThis.URL in mock for explicit scope reference - Add security note about URL pattern matching scope Co-authored-by: 7tg <20913374+7tg@users.noreply.github.com> --- mocks/vscode.ts | 3 ++- src/extension.ts | 11 ++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/mocks/vscode.ts b/mocks/vscode.ts index 08360cc..0ef8705 100644 --- a/mocks/vscode.ts +++ b/mocks/vscode.ts @@ -60,7 +60,8 @@ export class Uri { } static parse(value: string): Uri { - const parsed = new URL(value); + // Use globalThis.URL for explicit global scope reference + const parsed = new globalThis.URL(value); return new Uri( parsed.protocol.replace(":", ""), parsed.host, diff --git a/src/extension.ts b/src/extension.ts index 91fd64d..40ef684 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -97,11 +97,16 @@ export async function activate(context: vscode.ExtensionContext): Promise { canOpenExternalUri(uri: vscode.Uri): number | undefined { const urlString = uri.toString(); - // Check if URL contains "plannotator" substring (matches both localhost and remote URLs) + // Check if URL contains "plannotator" - this matches the Plannotator UI URL pattern + // More specific matching (like hostname) isn't feasible since Plannotator can run + // on any localhost port, and the pattern must match both development and production URLs + // Note: While this could theoretically match unintended URLs (e.g., http://example.com?q=plannotator), + // in practice this is only triggered by vscode.env.openExternal() calls from other extensions if (urlString.includes("plannotator")) { - return 2; // Priority to handle this URL + // Priority 2 (higher than default 0) to intercept these URLs before the default browser opener + return 2; } - return undefined; // Don't handle this URL + return undefined; // Don't handle this URL - let default browser opener handle it }, openExternalUri(uri: vscode.Uri): void { const urlString = uri.toString(); From 863de5b2bb428c435816f86bc80dce66e78a4c56 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 09:14:47 +0000 Subject: [PATCH 4/5] Fix URL matching to use localhost instead of "plannotator" substring Plannotator URLs don't contain "plannotator" in the path - they're just localhost:PORT URLs. Changed matching logic to: - Match http://localhost:* and https://localhost:* - Match http://127.0.0.1:* and https://127.0.0.1:* - Updated tests to verify localhost matching behavior Co-authored-by: 7tg <20913374+7tg@users.noreply.github.com> --- src/extension.test.ts | 14 ++++++++++---- src/extension.ts | 14 ++++++++------ 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/src/extension.test.ts b/src/extension.test.ts index 90b0f18..ec212d6 100644 --- a/src/extension.test.ts +++ b/src/extension.test.ts @@ -78,7 +78,7 @@ describe("activate", () => { expect(context.subscriptions.length).toBeGreaterThanOrEqual(4); }); - it("registers external URI opener for plannotator URLs", async () => { + it("registers external URI opener for localhost URLs", async () => { const windowWithOpener = vscode.window as typeof vscode.window & { registerExternalUriOpener?: (...args: any[]) => any; }; @@ -100,7 +100,7 @@ describe("activate", () => { ); }); - it("external URI opener returns priority for plannotator URLs", async () => { + it("external URI opener returns priority for localhost URLs", async () => { let capturedOpener: any; const windowWithOpener = vscode.window as typeof vscode.window & { registerExternalUriOpener?: (...args: any[]) => any; @@ -114,12 +114,18 @@ describe("activate", () => { await activate(context as unknown as vscode.ExtensionContext); - const testUri = vscode.Uri.parse("http://localhost:3000/plannotator"); + // Test localhost with port + const testUri = vscode.Uri.parse("http://localhost:3000/"); const priority = capturedOpener.canOpenExternalUri(testUri); expect(priority).toBe(2); + + // Test 127.0.0.1 with port + const testUri2 = vscode.Uri.parse("http://127.0.0.1:8080/review"); + const priority2 = capturedOpener.canOpenExternalUri(testUri2); + expect(priority2).toBe(2); }); - it("external URI opener returns undefined for non-plannotator URLs", async () => { + it("external URI opener returns undefined for non-localhost URLs", async () => { let capturedOpener: any; const windowWithOpener = vscode.window as typeof vscode.window & { registerExternalUriOpener?: (...args: any[]) => any; diff --git a/src/extension.ts b/src/extension.ts index 40ef684..33db8ce 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -97,12 +97,14 @@ export async function activate(context: vscode.ExtensionContext): Promise { canOpenExternalUri(uri: vscode.Uri): number | undefined { const urlString = uri.toString(); - // Check if URL contains "plannotator" - this matches the Plannotator UI URL pattern - // More specific matching (like hostname) isn't feasible since Plannotator can run - // on any localhost port, and the pattern must match both development and production URLs - // Note: While this could theoretically match unintended URLs (e.g., http://example.com?q=plannotator), - // in practice this is only triggered by vscode.env.openExternal() calls from other extensions - if (urlString.includes("plannotator")) { + // Match localhost URLs - Plannotator runs on localhost with dynamic ports + // This matches both http://localhost:PORT and http://127.0.0.1:PORT patterns + if ( + urlString.startsWith("http://localhost:") || + urlString.startsWith("https://localhost:") || + urlString.startsWith("http://127.0.0.1:") || + urlString.startsWith("https://127.0.0.1:") + ) { // Priority 2 (higher than default 0) to intercept these URLs before the default browser opener return 2; } From 548dd24b0ffc3640764e4ef5a136bfad3437a3e3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 09:15:21 +0000 Subject: [PATCH 5/5] Update README to reflect localhost URL matching Co-authored-by: 7tg <20913374+7tg@users.noreply.github.com> --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f50c1e0..073768c 100644 --- a/README.md +++ b/README.md @@ -32,8 +32,8 @@ When Plannotator opens a browser to show a plan review, this extension intercept ### For Claude Code as VS Code Extension: 1. The extension registers an external URI opener for HTTP/HTTPS URLs -2. When Claude Code (or any extension) tries to open a Plannotator URL via `vscode.env.openExternal()`, the opener intercepts it -3. If the URL contains "plannotator", it opens in a VS Code panel instead of an external browser +2. When Claude Code (or any extension) tries to open a localhost URL via `vscode.env.openExternal()`, the opener intercepts it +3. If the URL is a localhost URL (matching `http://localhost:*`, `https://localhost:*`, `http://127.0.0.1:*`, or `https://127.0.0.1:*`), it opens in a VS Code panel instead of an external browser ### Cookie Persistence: