diff --git a/packages/pi-coding-agent/src/modes/interactive/components/login-dialog.ts b/packages/pi-coding-agent/src/modes/interactive/components/login-dialog.ts index 20b62bc0c..bf9e8b4ed 100644 --- a/packages/pi-coding-agent/src/modes/interactive/components/login-dialog.ts +++ b/packages/pi-coding-agent/src/modes/interactive/components/login-dialog.ts @@ -1,7 +1,7 @@ // GSD Login Dialog Component — OAuth login flow UI // Copyright (c) 2026 Jeremy McSpadden import { getOAuthProviders } from "@gsd/pi-ai/oauth"; -import { Container, type Focusable, getEditorKeybindings, Input, Spacer, Text, type TUI } from "@gsd/pi-tui"; +import { Container, type Focusable, getEditorKeybindings, Input, Spacer, Text, truncateToWidth, type TUI } from "@gsd/pi-tui"; import { execFile } from "child_process"; import { theme } from "../theme/theme.js"; import { DynamicBorder } from "./dynamic-border.js"; @@ -121,21 +121,25 @@ export class LoginDialogComponent extends Container implements Focusable { showAuth(url: string, instructions?: string): void { this.contentContainer.clear(); this.contentContainer.addChild(new Spacer(1)); - this.contentContainer.addChild(new Text(theme.fg("accent", url), 1, 0)); + + // Truncate the visible URL text so it never wraps (which would break + // the OSC 8 hyperlink). The full URL is still the link target. + const maxUrlWidth = Math.max(20, this.tui.terminal.columns - 4); + const displayUrl = truncateToWidth(url, maxUrlWidth); + const urlLink = `\x1b]8;;${url}\x07${theme.fg("accent", displayUrl)}\x1b]8;;\x07`; + this.contentContainer.addChild(new Text(urlLink, 1, 0)); const clickHint = process.platform === "darwin" ? "Cmd+click to open" : "Ctrl+click to open"; - const hyperlink = `\x1b]8;;${url}\x07${clickHint}\x1b]8;;\x07`; - this.contentContainer.addChild(new Text(theme.fg("dim", hyperlink), 1, 0)); + this.contentContainer.addChild(new Text(theme.fg("dim", clickHint), 1, 0)); if (instructions) { this.contentContainer.addChild(new Spacer(1)); this.contentContainer.addChild(new Text(theme.fg("warning", instructions), 1, 0)); } - // Try to open browser — on Windows, `start` needs an empty title arg - // so it treats the URL as a target, not a window title + // PowerShell's Start-Process handles URLs with '&' safely; cmd /c start does not. if (process.platform === "win32") { - execFile("cmd", ["/c", "start", "", url], () => {}); + execFile("powershell", ["-c", `Start-Process '${url.replace(/'/g, "''")}'`], () => {}); } else { const openCmd = process.platform === "darwin" ? "open" : "xdg-open"; execFile(openCmd, [url], () => {}); diff --git a/src/onboarding.ts b/src/onboarding.ts index 32bff8bf9..eafe1d443 100644 --- a/src/onboarding.ts +++ b/src/onboarding.ts @@ -124,7 +124,8 @@ async function loadPico(): Promise { /** Open a URL in the system browser (best-effort, non-blocking) */ function openBrowser(url: string): void { if (process.platform === 'win32') { - execFile('cmd', ['/c', 'start', '', url], () => {}) + // PowerShell's Start-Process handles URLs with '&' safely; cmd /c start does not. + execFile('powershell', ['-c', `Start-Process '${url.replace(/'/g, "''")}'`], () => {}) } else { const cmd = process.platform === 'darwin' ? 'open' : 'xdg-open' execFile(cmd, [url], () => {}) diff --git a/src/resources/extensions/gsd/export.ts b/src/resources/extensions/gsd/export.ts index 009f63659..bfac9cb25 100644 --- a/src/resources/extensions/gsd/export.ts +++ b/src/resources/extensions/gsd/export.ts @@ -4,7 +4,7 @@ import type { ExtensionCommandContext } from "@gsd/pi-coding-agent"; import { writeFileSync, mkdirSync } from "node:fs"; import { join, basename } from "node:path"; -import { exec } from "node:child_process"; +import { exec, execFile } from "node:child_process"; import { getLedger, getProjectTotals, aggregateByPhase, aggregateBySlice, aggregateByModel, formatCost, formatTokenCount, loadLedgerFromDisk, @@ -20,20 +20,13 @@ import { getErrorMessage } from "./error-utils.js"; * Non-blocking, non-fatal — failures are silently ignored. */ export function openInBrowser(filePath: string): void { - const cmd = - process.platform === "darwin" ? "open" : - process.platform === "win32" ? "start" : - "xdg-open"; - - // On Windows, `start` needs an empty title argument when the path has spaces - const args = process.platform === "win32" - ? `"" "${filePath}"` - : `"${filePath}"`; - - exec(`${cmd} ${args}`, (err) => { - // Non-fatal — if the browser can't be opened, the file path is still shown - if (err) void err; - }); + if (process.platform === "win32") { + // PowerShell's Start-Process handles paths with '&' and spaces safely. + execFile("powershell", ["-c", `Start-Process '${filePath.replace(/'/g, "''")}'`], () => {}); + } else { + const cmd = process.platform === "darwin" ? "open" : "xdg-open"; + execFile(cmd, [filePath], () => {}); + } } /** diff --git a/src/web-mode.ts b/src/web-mode.ts index 0b8b9de28..3daa0e267 100644 --- a/src/web-mode.ts +++ b/src/web-mode.ts @@ -1,5 +1,5 @@ import { randomBytes } from 'node:crypto' -import { exec, spawn, type ChildProcess, type SpawnOptions } from 'node:child_process' +import { exec, execFile, spawn, type ChildProcess, type SpawnOptions } from 'node:child_process' import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' import { request as httpRequest } from 'node:http' import { createServer } from 'node:net' @@ -12,12 +12,13 @@ const DEFAULT_PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '. /** Open a URL in the user's default browser. */ function openBrowser(url: string): void { - const cmd = process.platform === 'darwin' ? 'open' : - process.platform === 'win32' ? 'start' : - 'xdg-open' - exec(`${cmd} "${url}"`, () => { - // Ignore errors — user can manually open the URL - }) + if (process.platform === 'win32') { + // PowerShell's Start-Process handles URLs with '&' safely; cmd /c start does not. + execFile('powershell', ['-c', `Start-Process '${url.replace(/'/g, "''")}'`], () => {}) + } else { + const cmd = process.platform === 'darwin' ? 'open' : 'xdg-open' + execFile(cmd, [url], () => {}) + } } type WritableLike = Pick