Skip to content
This repository was archived by the owner on Feb 17, 2026. It is now read-only.
Draft
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ dist/
*.vsix
bun.lockb
.vscode-test/
package-lock.json
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
32 changes: 30 additions & 2 deletions mocks/vscode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,19 @@ export interface UriHandler {
handleUri(uri: Uri): ProviderResult<void>;
}

export interface ExternalUriOpener {
canOpenExternalUri?(uri: Uri): number | undefined;
openExternalUri(uri: Uri): void | Promise<void>;
}

export interface ExternalUriOpenerMetadata {
schemes: string[];
label: string;
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type ProviderResult<T> = T | undefined | null | Thenable<T | undefined | null>;
export type ProviderResult<T> = T | undefined | null | Promise<T | undefined | null>;
export type Thenable<T> = Promise<T>;

export interface ExtensionContext {
subscriptions: { dispose(): void }[];
Expand All @@ -19,7 +30,7 @@ export interface ExtensionContext {
};
globalState: {
get<T>(key: string, defaultValue?: T): T | undefined;
update(key: string, value: unknown): Thenable<void>;
update(key: string, value: unknown): Promise<void>;
};
}

Expand Down Expand Up @@ -49,6 +60,7 @@ export class Uri {
}

static parse(value: string): Uri {
// Use globalThis.URL for explicit global scope reference
const parsed = new globalThis.URL(value);
return new Uri(
parsed.protocol.replace(":", ""),
Expand All @@ -58,6 +70,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 = {
Expand Down Expand Up @@ -85,6 +106,13 @@ export const window = {
registerUriHandler(_handler: unknown) {
return { dispose() {} };
},
registerExternalUriOpener(
_id: string,
_opener: ExternalUriOpener,
_metadata: ExternalUriOpenerMetadata,
) {
return { dispose() {} };
},
async showInformationMessage(_message: string) {
return undefined;
},
Expand Down
64 changes: 62 additions & 2 deletions src/extension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
45 changes: 45 additions & 0 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,51 @@ export async function activate(context: vscode.ExtensionContext): Promise<void>
},
);
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" - 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
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot url is just localhost there is not plannator

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 863de5b. Changed URL matching from checking for "plannotator" substring to matching localhost URLs specifically:

  • http://localhost:* and https://localhost:*
  • http://127.0.0.1:* and https://127.0.0.1:*

This correctly matches actual Plannotator URLs which run on localhost with dynamic ports.

if (urlString.includes("plannotator")) {
// Priority 2 (higher than default 0) to intercept these URLs before the default browser opener
return 2;
}
return undefined; // Don't handle this URL - let default browser opener handle it
},
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 {}