diff --git a/.gitignore b/.gitignore index 50bb2eb9..999c6636 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,9 @@ plannotator-local # Local goal setup packages generated by the setup-goal skill. /goals/ *.bun-build + +# Local meta notes (not upstream) +/meta/ + +# Local Pi state/memory (not upstream) +/.pi/ diff --git a/apps/pi-extension/package.json b/apps/pi-extension/package.json index 90565882..39b53a05 100644 --- a/apps/pi-extension/package.json +++ b/apps/pi-extension/package.json @@ -41,9 +41,10 @@ "prepublishOnly": "cd ../.. && bun run build:pi" }, "dependencies": { + "@joplin/turndown-plugin-gfm": "^1.0.64", "@pierre/diffs": "^1.1.12", - "turndown": "^7.2.4", - "@joplin/turndown-plugin-gfm": "^1.0.64" + "glimpseui": "^0.8.0", + "turndown": "^7.2.4" }, "peerDependencies": { "@earendil-works/pi-coding-agent": ">=0.74.0" diff --git a/apps/pi-extension/plannotator-browser.ts b/apps/pi-extension/plannotator-browser.ts index e71b20a2..6cc23ff6 100644 --- a/apps/pi-extension/plannotator-browser.ts +++ b/apps/pi-extension/plannotator-browser.ts @@ -80,8 +80,8 @@ export function getStartupErrorMessage(err: unknown): string { return err instanceof Error ? err.message : "Unknown error"; } -function openBrowserForServer(serverUrl: string, ctx: ExtensionContext): void { - const browserResult = openBrowser(serverUrl); +async function openBrowserForServer(serverUrl: string, ctx: ExtensionContext): Promise { + const browserResult = await openBrowser(serverUrl); if (isRemoteSession()) { ctx.ui.notify(`[Plannotator] ${serverUrl}`, "info"); } else if (!browserResult.opened) { @@ -94,7 +94,7 @@ async function openBrowserAndWait( ctx: ExtensionContext, waitForResult: () => Promise, ): Promise { - openBrowserForServer(server.url, ctx); + await openBrowserForServer(server.url, ctx); return waitForDecisionWithCleanup(server, waitForResult); } diff --git a/apps/pi-extension/server/network.test.ts b/apps/pi-extension/server/network.test.ts index 174e7157..74a331a1 100644 --- a/apps/pi-extension/server/network.test.ts +++ b/apps/pi-extension/server/network.test.ts @@ -1,8 +1,21 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { getServerHostname, getServerPort, isRemoteSession } from "./network"; +import { + getServerHostname, + getServerPort, + isNoOpBrowserSentinel, + isRemoteSession, + openBrowser, +} from "./network"; const savedEnv: Record = {}; -const envKeys = ["PLANNOTATOR_REMOTE", "PLANNOTATOR_PORT", "SSH_TTY", "SSH_CONNECTION"]; +const envKeys = [ + "PLANNOTATOR_REMOTE", + "PLANNOTATOR_PORT", + "SSH_TTY", + "SSH_CONNECTION", + "PLANNOTATOR_BROWSER", + "BROWSER", +]; function clearEnv() { for (const key of envKeys) { @@ -107,3 +120,39 @@ describe("pi server hostname", () => { expect(getServerHostname()).toBe("0.0.0.0"); }); }); + +describe("pi browser no-op sentinels", () => { + test("recognizes no-op values case- and whitespace-insensitively", () => { + for (const value of [ + "true", + "false", + "none", + ":", + "0", + "1", + "TRUE", + " none ", + ]) { + expect(isNoOpBrowserSentinel(value)).toBe(true); + } + }); + + test("does not flag real browser handlers or explicit command paths", () => { + expect(isNoOpBrowserSentinel("/usr/bin/firefox")).toBe(false); + expect(isNoOpBrowserSentinel("Google Chrome")).toBe(false); + expect(isNoOpBrowserSentinel("open")).toBe(false); + expect(isNoOpBrowserSentinel("/usr/bin/true")).toBe(false); + }); + + test("remote BROWSER=true is treated as no browser handler", async () => { + clearEnv(); + process.env.PLANNOTATOR_REMOTE = "1"; + process.env.BROWSER = "true"; + + expect(await openBrowser("http://127.0.0.1:19432")).toEqual({ + opened: false, + isRemote: true, + url: "http://127.0.0.1:19432", + }); + }); +}); diff --git a/apps/pi-extension/server/network.ts b/apps/pi-extension/server/network.ts index 083c05c1..b15df20a 100644 --- a/apps/pi-extension/server/network.ts +++ b/apps/pi-extension/server/network.ts @@ -4,11 +4,19 @@ */ import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; import type { Server } from "node:http"; import { release } from "node:os"; +import { delimiter, join } from "node:path"; const DEFAULT_REMOTE_PORT = 19432; const LOOPBACK_HOST = "127.0.0.1"; +const NOOP_BROWSER_VALUES = new Set(["true", "false", "none", ":", "0", "1"]); + +export function isNoOpBrowserSentinel(value: string | undefined): boolean { + if (!value) return false; + return NOOP_BROWSER_VALUES.has(value.trim().toLowerCase()); +} /** * Check if running in a remote session (SSH, devcontainer, etc.) @@ -123,16 +131,102 @@ export async function listenOnPort( * Honors PLANNOTATOR_BROWSER and BROWSER env vars. * Returns { opened: true } if browser was opened, { opened: false, isRemote: true, url } if remote session. */ -export function openBrowser(url: string): { +function findCommandOnPath(command: string): string | null { + const extensions = process.platform === "win32" ? [".cmd", ".exe", ".bat", ""] : [""]; + for (const dir of (process.env.PATH || "").split(delimiter)) { + if (!dir) continue; + for (const ext of extensions) { + const candidate = join(dir, `${command}${ext}`); + if (existsSync(candidate)) return candidate; + } + } + return null; +} + +function buildGlimpseHtml(url: string): string { + const encodedUrl = JSON.stringify(url); + return ` + + + + Plannotator + + + + + +`; +} + +async function openGlimpse(url: string): Promise { + const glimpseCli = findCommandOnPath("glimpseui"); + if (!glimpseCli) return false; + + const args = [ + "--width", + String(Number(process.env.PLANNOTATOR_GLIMPSE_WIDTH || 1280)), + "--height", + String(Number(process.env.PLANNOTATOR_GLIMPSE_HEIGHT || 900)), + "--title", + "Plannotator", + "--open-links", + ]; + const html = buildGlimpseHtml(url); + + return await new Promise((resolve) => { + let settled = false; + let successTimer: ReturnType | undefined; + const finish = (opened: boolean) => { + if (settled) return; + settled = true; + if (successTimer) clearTimeout(successTimer); + resolve(opened); + }; + + const child = spawn(glimpseCli, args, { + detached: true, + stdio: ["pipe", "ignore", "ignore"], + }); + successTimer = setTimeout(() => { + child.unref(); + finish(true); + }, 750); + + child.once("error", () => finish(false)); + child.once("exit", () => finish(false)); + child.stdin.once("error", () => finish(false)); + child.stdin.end(html); + }); +} + +export async function openBrowser(url: string): Promise<{ opened: boolean; isRemote?: boolean; url?: string; -} { - const browser = process.env.PLANNOTATOR_BROWSER || process.env.BROWSER; +}> { + const rawPlannotatorBrowser = process.env.PLANNOTATOR_BROWSER; + const rawBrowser = process.env.BROWSER; + const plannotatorBrowser = isNoOpBrowserSentinel(rawPlannotatorBrowser) + ? undefined + : rawPlannotatorBrowser; + const envBrowser = isNoOpBrowserSentinel(rawBrowser) ? undefined : rawBrowser; + const browser = plannotatorBrowser || envBrowser; if (isRemoteSession() && !browser) { return { opened: false, isRemote: true, url }; } + if (!browser) { + const openedViaGlimpse = await openGlimpse(url); + if (openedViaGlimpse) { + return { opened: true }; + } + } + try { const platform = process.platform; const wsl = @@ -142,12 +236,12 @@ export function openBrowser(url: string): { let args: string[]; if (browser) { - if (process.env.PLANNOTATOR_BROWSER && platform === "darwin") { + if (plannotatorBrowser && platform === "darwin") { cmd = "open"; - args = ["-a", browser, url]; - } else if (platform === "win32" || wsl) { + args = ["-a", plannotatorBrowser, url]; + } else if ((platform === "win32" || wsl) && plannotatorBrowser) { cmd = "cmd.exe"; - args = ["/c", "start", "", browser, url]; + args = ["/c", "start", "", plannotatorBrowser, url]; } else { cmd = browser; args = [url]; diff --git a/bun.lock b/bun.lock index 7a24fde9..f06a1689 100644 --- a/bun.lock +++ b/bun.lock @@ -64,7 +64,7 @@ }, "apps/opencode-plugin": { "name": "@plannotator/opencode", - "version": "0.19.24", + "version": "0.19.26", "dependencies": { "@opencode-ai/plugin": "^1.1.10", }, @@ -86,10 +86,11 @@ }, "apps/pi-extension": { "name": "@plannotator/pi-extension", - "version": "0.19.24", + "version": "0.19.26", "dependencies": { "@joplin/turndown-plugin-gfm": "^1.0.64", "@pierre/diffs": "^1.1.12", + "glimpseui": "^0.8.0", "turndown": "^7.2.4", }, "devDependencies": { @@ -201,11 +202,12 @@ }, "packages/server": { "name": "@plannotator/server", - "version": "0.19.24", + "version": "0.19.26", "dependencies": { "@pierre/diffs": "^1.1.12", "@plannotator/ai": "workspace:*", "@plannotator/shared": "workspace:*", + "glimpseui": "^0.8.0", }, "peerDependencies": { "bun": ">=1.0.0", @@ -1686,6 +1688,8 @@ "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], + "glimpseui": ["glimpseui@0.8.0", "", { "os": [ "linux", "win32", "darwin", ], "bin": { "glimpseui": "bin/glimpse.mjs" } }, "sha512-trpEKiULW9sGjsLqtSH/+hMPcohvq3tkN6yM55t0WIgTsHt53+HXVTMf5ipy/w+kXbkbC/XP7DSRvzZC+E8xig=="], + "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], diff --git a/packages/server/browser.test.ts b/packages/server/browser.test.ts index 73945567..24c68dab 100644 --- a/packages/server/browser.test.ts +++ b/packages/server/browser.test.ts @@ -44,7 +44,7 @@ describe("shouldTryRemoteBrowserFallback", () => { expect(shouldTryRemoteBrowserFallback(true)).toBe(false); }); - test("true for remote sessions when BROWSER is a no-op sentinel (e.g. agent view)", () => { + test("true for remote sessions when BROWSER is a no-op sentinel", () => { clearEnv(); process.env.BROWSER = "true"; expect(shouldTryRemoteBrowserFallback(true)).toBe(true); @@ -58,20 +58,30 @@ describe("shouldTryRemoteBrowserFallback", () => { }); describe("isNoOpBrowserSentinel", () => { - test("returns false for undefined / empty", () => { + test("returns false for undefined and empty values", () => { expect(isNoOpBrowserSentinel(undefined)).toBe(false); expect(isNoOpBrowserSentinel("")).toBe(false); }); - test("recognises the documented no-op values, case- and whitespace-insensitive", () => { - for (const v of ["true", "false", "none", ":", "0", "1", "TRUE", " none "]) { - expect(isNoOpBrowserSentinel(v)).toBe(true); + test("recognizes no-op values case- and whitespace-insensitively", () => { + for (const value of [ + "true", + "false", + "none", + ":", + "0", + "1", + "TRUE", + " none ", + ]) { + expect(isNoOpBrowserSentinel(value)).toBe(true); } }); - test("does not flag real browser handlers", () => { + test("does not flag real browser handlers or explicit command paths", () => { expect(isNoOpBrowserSentinel("/usr/bin/firefox")).toBe(false); expect(isNoOpBrowserSentinel("Google Chrome")).toBe(false); expect(isNoOpBrowserSentinel("open")).toBe(false); + expect(isNoOpBrowserSentinel("/usr/bin/true")).toBe(false); }); }); diff --git a/packages/server/browser.ts b/packages/server/browser.ts index d48802e7..64d60a04 100644 --- a/packages/server/browser.ts +++ b/packages/server/browser.ts @@ -3,6 +3,7 @@ */ import { $ } from "bun"; +import { spawn } from "node:child_process"; import os from "node:os"; import path from "node:path"; import fs from "node:fs"; @@ -102,9 +103,70 @@ export function shouldTryRemoteBrowserFallback(isRemote: boolean): boolean { return !hasRealHandler; } +function buildGlimpseHtml(url: string): string { + const encodedUrl = JSON.stringify(url); + return ` + + + + Plannotator + + + + + +`; +} + +async function openGlimpse(url: string): Promise { + const glimpseCli = Bun.which("glimpseui"); + if (!glimpseCli) return false; + + const args = [ + "--width", + String(Number(process.env.PLANNOTATOR_GLIMPSE_WIDTH || 1280)), + "--height", + String(Number(process.env.PLANNOTATOR_GLIMPSE_HEIGHT || 900)), + "--title", + "Plannotator", + "--open-links", + ]; + const html = buildGlimpseHtml(url); + + return await new Promise((resolve) => { + let settled = false; + let successTimer: ReturnType | undefined; + const finish = (opened: boolean) => { + if (settled) return; + settled = true; + if (successTimer) clearTimeout(successTimer); + resolve(opened); + }; + + const child = spawn(glimpseCli, args, { + detached: true, + stdio: ["pipe", "ignore", "ignore"], + }); + successTimer = setTimeout(() => { + child.unref(); + finish(true); + }, 750); + + child.once("error", () => finish(false)); + child.once("exit", () => finish(false)); + child.stdin.once("error", () => finish(false)); + child.stdin.end(html); + }); +} + export async function openBrowser( url: string, - options?: { isRemote?: boolean } + options?: { isRemote?: boolean; useGlimpse?: boolean } ): Promise { try { const rawPlannotatorBrowser = process.env.PLANNOTATOR_BROWSER; @@ -114,13 +176,21 @@ export async function openBrowser( : rawPlannotatorBrowser; const envBrowser = isNoOpBrowserSentinel(rawBrowser) ? undefined : rawBrowser; const browser = plannotatorBrowser || envBrowser; - if (shouldTryRemoteBrowserFallback(options?.isRemote ?? false)) { + const isRemote = options?.isRemote ?? false; + if (shouldTryRemoteBrowserFallback(isRemote)) { const openedViaIpc = await tryVscodeIpc(url); if (openedViaIpc) { return true; } } + if (options?.useGlimpse && !browser && !isRemote) { + const openedViaGlimpse = await openGlimpse(url); + if (openedViaGlimpse) { + return true; + } + } + const platform = process.platform; const wsl = await isWSL(); diff --git a/packages/server/package.json b/packages/server/package.json index a09a41bb..666d95f8 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -27,8 +27,9 @@ ], "dependencies": { "@pierre/diffs": "^1.1.12", + "@plannotator/ai": "workspace:*", "@plannotator/shared": "workspace:*", - "@plannotator/ai": "workspace:*" + "glimpseui": "^0.8.0" }, "peerDependencies": { "bun": ">=1.0.0" diff --git a/packages/server/shared-handlers.ts b/packages/server/shared-handlers.ts index 92180111..7f48d82c 100644 --- a/packages/server/shared-handlers.ts +++ b/packages/server/shared-handlers.ts @@ -173,5 +173,5 @@ export async function handleServerReady( const skipBrowserOpen = options.skipBrowserOpen ?? process.env.PLANNOTATOR_SKIP_BROWSER_OPEN === "1"; if (skipBrowserOpen) return; - await (options.openBrowser ?? openBrowserImpl)(url, { isRemote }); + await (options.openBrowser ?? openBrowserImpl)(url, { isRemote, useGlimpse: true }); } diff --git a/packages/ui/hooks/useAutoClose.ts b/packages/ui/hooks/useAutoClose.ts index a68d9daf..3ab45937 100644 --- a/packages/ui/hooks/useAutoClose.ts +++ b/packages/ui/hooks/useAutoClose.ts @@ -23,7 +23,33 @@ interface UseAutoCloseReturn { enableAndStart: () => void; } +type GlimpseWindow = Window & { + glimpse?: { + close?: () => void; + }; +}; + +function requestGlimpseClose(): boolean { + const glimpseClose = (window as GlimpseWindow).glimpse?.close; + if (typeof glimpseClose === 'function') { + glimpseClose(); + return true; + } + + if (window.parent && window.parent !== window) { + window.parent.postMessage({ __plannotator_glimpse_close: true }, '*'); + return true; + } + + return false; +} + function tryClose(onFail: () => void): void { + const requestedNativeClose = requestGlimpseClose(); + if (requestedNativeClose) { + return; + } + window.close(); // window.close() is silently ignored when the tab wasn't opened by script. // Check after a short delay whether we're still alive.