diff --git a/packages/ai/src/utils/oauth/github-copilot.ts b/packages/ai/src/utils/oauth/github-copilot.ts index 744d9f18..06661fb9 100644 --- a/packages/ai/src/utils/oauth/github-copilot.ts +++ b/packages/ai/src/utils/oauth/github-copilot.ts @@ -136,17 +136,45 @@ async function startDeviceFlow(domain: string): Promise { }; } +/** + * Sleep that can be interrupted by an AbortSignal + */ +function abortableSleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new Error("Login cancelled")); + return; + } + + const timeout = setTimeout(resolve, ms); + + signal?.addEventListener( + "abort", + () => { + clearTimeout(timeout); + reject(new Error("Login cancelled")); + }, + { once: true }, + ); + }); +} + async function pollForGitHubAccessToken( domain: string, deviceCode: string, intervalSeconds: number, expiresIn: number, + signal?: AbortSignal, ) { const urls = getUrls(domain); const deadline = Date.now() + expiresIn * 1000; let intervalMs = Math.max(1000, Math.floor(intervalSeconds * 1000)); while (Date.now() < deadline) { + if (signal?.aborted) { + throw new Error("Login cancelled"); + } + const raw = await fetchJson(urls.accessTokenUrl, { method: "POST", headers: { @@ -168,20 +196,20 @@ async function pollForGitHubAccessToken( if (raw && typeof raw === "object" && typeof (raw as DeviceTokenErrorResponse).error === "string") { const err = (raw as DeviceTokenErrorResponse).error; if (err === "authorization_pending") { - await new Promise((resolve) => setTimeout(resolve, intervalMs)); + await abortableSleep(intervalMs, signal); continue; } if (err === "slow_down") { intervalMs += 5000; - await new Promise((resolve) => setTimeout(resolve, intervalMs)); + await abortableSleep(intervalMs, signal); continue; } throw new Error(`Device flow failed: ${err}`); } - await new Promise((resolve) => setTimeout(resolve, intervalMs)); + await abortableSleep(intervalMs, signal); } throw new Error("Device flow timed out"); @@ -274,11 +302,13 @@ async function enableAllGitHubCopilotModels( * @param options.onAuth - Callback with URL and optional instructions (user code) * @param options.onPrompt - Callback to prompt user for input * @param options.onProgress - Optional progress callback + * @param options.signal - Optional AbortSignal for cancellation */ export async function loginGitHubCopilot(options: { onAuth: (url: string, instructions?: string) => void; onPrompt: (prompt: { message: string; placeholder?: string; allowEmpty?: boolean }) => Promise; onProgress?: (message: string) => void; + signal?: AbortSignal; }): Promise { const input = await options.onPrompt({ message: "GitHub Enterprise URL/domain (blank for github.com)", @@ -286,6 +316,10 @@ export async function loginGitHubCopilot(options: { allowEmpty: true, }); + if (options.signal?.aborted) { + throw new Error("Login cancelled"); + } + const trimmed = input.trim(); const enterpriseDomain = normalizeDomain(input); if (trimmed && !enterpriseDomain) { @@ -301,6 +335,7 @@ export async function loginGitHubCopilot(options: { device.device_code, device.interval, device.expires_in, + options.signal, ); const credentials = await refreshGitHubCopilotToken(githubAccessToken, enterpriseDomain ?? undefined); diff --git a/packages/ai/src/utils/oauth/google-antigravity.ts b/packages/ai/src/utils/oauth/google-antigravity.ts index 16f45c51..86428f49 100644 --- a/packages/ai/src/utils/oauth/google-antigravity.ts +++ b/packages/ai/src/utils/oauth/google-antigravity.ts @@ -33,23 +33,21 @@ const TOKEN_URL = "https://oauth2.googleapis.com/token"; // Fallback project ID when discovery fails const DEFAULT_PROJECT_ID = "rising-fact-p41fc"; +type CallbackServerInfo = { + server: Server; + cancelWait: () => void; + waitForCode: () => Promise<{ code: string; state: string } | null>; +}; + /** * Start a local HTTP server to receive the OAuth callback */ -async function startCallbackServer(): Promise<{ - server: Server; - getCode: () => Promise<{ code: string; state: string }>; -}> { +async function startCallbackServer(): Promise { const { createServer } = await import("http"); return new Promise((resolve, reject) => { - let codeResolve: (value: { code: string; state: string }) => void; - let codeReject: (error: Error) => void; - - const codePromise = new Promise<{ code: string; state: string }>((res, rej) => { - codeResolve = res; - codeReject = rej; - }); + let result: { code: string; state: string } | null = null; + let cancelled = false; const server = createServer((req, res) => { const url = new URL(req.url || "", `http://localhost:51121`); @@ -64,7 +62,6 @@ async function startCallbackServer(): Promise<{ res.end( `

Authentication Failed

Error: ${error}

You can close this window.

`, ); - codeReject(new Error(`OAuth error: ${error}`)); return; } @@ -73,13 +70,12 @@ async function startCallbackServer(): Promise<{ res.end( `

Authentication Successful

You can close this window and return to the terminal.

`, ); - codeResolve({ code, state }); + result = { code, state }; } else { res.writeHead(400, { "Content-Type": "text/html" }); res.end( `

Authentication Failed

Missing code or state parameter.

`, ); - codeReject(new Error("Missing code or state in callback")); } } else { res.writeHead(404); @@ -94,12 +90,40 @@ async function startCallbackServer(): Promise<{ server.listen(51121, "127.0.0.1", () => { resolve({ server, - getCode: () => codePromise, + cancelWait: () => { + cancelled = true; + }, + waitForCode: async () => { + const sleep = () => new Promise((r) => setTimeout(r, 100)); + while (!result && !cancelled) { + await sleep(); + } + return result; + }, }); }); }); } +/** + * Parse redirect URL to extract code and state + */ +function parseRedirectUrl(input: string): { code?: string; state?: string } { + const value = input.trim(); + if (!value) return {}; + + try { + const url = new URL(value); + return { + code: url.searchParams.get("code") ?? undefined, + state: url.searchParams.get("state") ?? undefined, + }; + } catch { + // Not a URL, return empty + return {}; + } +} + interface LoadCodeAssistPayload { cloudaicompanionProject?: string | { id?: string }; currentTier?: { id?: string }; @@ -226,16 +250,21 @@ export async function refreshAntigravityToken(refreshToken: string, projectId: s * * @param onAuth - Callback with URL and optional instructions * @param onProgress - Optional progress callback + * @param onManualCodeInput - Optional promise that resolves with user-pasted redirect URL. + * Races with browser callback - whichever completes first wins. */ export async function loginAntigravity( onAuth: (info: { url: string; instructions?: string }) => void, onProgress?: (message: string) => void, + onManualCodeInput?: () => Promise, ): Promise { const { verifier, challenge } = await generatePKCE(); // Start local server for callback onProgress?.("Starting local server for OAuth callback..."); - const { server, getCode } = await startCallbackServer(); + const server = await startCallbackServer(); + + let code: string | undefined; try { // Build authorization URL @@ -256,16 +285,75 @@ export async function loginAntigravity( // Notify caller with URL to open onAuth({ url: authUrl, - instructions: "Complete the sign-in in your browser. The callback will be captured automatically.", + instructions: "Complete the sign-in in your browser.", }); - // Wait for the callback + // Wait for the callback, racing with manual input if provided onProgress?.("Waiting for OAuth callback..."); - const { code, state } = await getCode(); - // Verify state matches - if (state !== verifier) { - throw new Error("OAuth state mismatch - possible CSRF attack"); + if (onManualCodeInput) { + // Race between browser callback and manual input + let manualInput: string | undefined; + let manualError: Error | undefined; + const manualPromise = onManualCodeInput() + .then((input) => { + manualInput = input; + server.cancelWait(); + }) + .catch((err) => { + manualError = err instanceof Error ? err : new Error(String(err)); + server.cancelWait(); + }); + + const result = await server.waitForCode(); + + // If manual input was cancelled, throw that error + if (manualError) { + throw manualError; + } + + if (result?.code) { + // Browser callback won - verify state + if (result.state !== verifier) { + throw new Error("OAuth state mismatch - possible CSRF attack"); + } + code = result.code; + } else if (manualInput) { + // Manual input won + const parsed = parseRedirectUrl(manualInput); + if (parsed.state && parsed.state !== verifier) { + throw new Error("OAuth state mismatch - possible CSRF attack"); + } + code = parsed.code; + } + + // If still no code, wait for manual promise and try that + if (!code) { + await manualPromise; + if (manualError) { + throw manualError; + } + if (manualInput) { + const parsed = parseRedirectUrl(manualInput); + if (parsed.state && parsed.state !== verifier) { + throw new Error("OAuth state mismatch - possible CSRF attack"); + } + code = parsed.code; + } + } + } else { + // Original flow: just wait for callback + const result = await server.waitForCode(); + if (result?.code) { + if (result.state !== verifier) { + throw new Error("OAuth state mismatch - possible CSRF attack"); + } + code = result.code; + } + } + + if (!code) { + throw new Error("No authorization code received"); } // Exchange code for tokens @@ -320,6 +408,6 @@ export async function loginAntigravity( return credentials; } finally { - server.close(); + server.server.close(); } } diff --git a/packages/ai/src/utils/oauth/google-gemini-cli.ts b/packages/ai/src/utils/oauth/google-gemini-cli.ts index d3585d15..e5e088d0 100644 --- a/packages/ai/src/utils/oauth/google-gemini-cli.ts +++ b/packages/ai/src/utils/oauth/google-gemini-cli.ts @@ -25,23 +25,21 @@ const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; const TOKEN_URL = "https://oauth2.googleapis.com/token"; const CODE_ASSIST_ENDPOINT = "https://cloudcode-pa.googleapis.com"; +type CallbackServerInfo = { + server: Server; + cancelWait: () => void; + waitForCode: () => Promise<{ code: string; state: string } | null>; +}; + /** * Start a local HTTP server to receive the OAuth callback */ -async function startCallbackServer(): Promise<{ - server: Server; - getCode: () => Promise<{ code: string; state: string }>; -}> { +async function startCallbackServer(): Promise { const { createServer } = await import("http"); return new Promise((resolve, reject) => { - let codeResolve: (value: { code: string; state: string }) => void; - let codeReject: (error: Error) => void; - - const codePromise = new Promise<{ code: string; state: string }>((res, rej) => { - codeResolve = res; - codeReject = rej; - }); + let result: { code: string; state: string } | null = null; + let cancelled = false; const server = createServer((req, res) => { const url = new URL(req.url || "", `http://localhost:8085`); @@ -56,7 +54,6 @@ async function startCallbackServer(): Promise<{ res.end( `

Authentication Failed

Error: ${error}

You can close this window.

`, ); - codeReject(new Error(`OAuth error: ${error}`)); return; } @@ -65,13 +62,12 @@ async function startCallbackServer(): Promise<{ res.end( `

Authentication Successful

You can close this window and return to the terminal.

`, ); - codeResolve({ code, state }); + result = { code, state }; } else { res.writeHead(400, { "Content-Type": "text/html" }); res.end( `

Authentication Failed

Missing code or state parameter.

`, ); - codeReject(new Error("Missing code or state in callback")); } } else { res.writeHead(404); @@ -86,12 +82,40 @@ async function startCallbackServer(): Promise<{ server.listen(8085, "127.0.0.1", () => { resolve({ server, - getCode: () => codePromise, + cancelWait: () => { + cancelled = true; + }, + waitForCode: async () => { + const sleep = () => new Promise((r) => setTimeout(r, 100)); + while (!result && !cancelled) { + await sleep(); + } + return result; + }, }); }); }); } +/** + * Parse redirect URL to extract code and state + */ +function parseRedirectUrl(input: string): { code?: string; state?: string } { + const value = input.trim(); + if (!value) return {}; + + try { + const url = new URL(value); + return { + code: url.searchParams.get("code") ?? undefined, + state: url.searchParams.get("state") ?? undefined, + }; + } catch { + // Not a URL, return empty + return {}; + } +} + interface LoadCodeAssistPayload { cloudaicompanionProject?: string; currentTier?: { id?: string }; @@ -257,16 +281,21 @@ export async function refreshGoogleCloudToken(refreshToken: string, projectId: s * * @param onAuth - Callback with URL and optional instructions * @param onProgress - Optional progress callback + * @param onManualCodeInput - Optional promise that resolves with user-pasted redirect URL. + * Races with browser callback - whichever completes first wins. */ export async function loginGeminiCli( onAuth: (info: { url: string; instructions?: string }) => void, onProgress?: (message: string) => void, + onManualCodeInput?: () => Promise, ): Promise { const { verifier, challenge } = await generatePKCE(); // Start local server for callback onProgress?.("Starting local server for OAuth callback..."); - const { server, getCode } = await startCallbackServer(); + const server = await startCallbackServer(); + + let code: string | undefined; try { // Build authorization URL @@ -287,16 +316,75 @@ export async function loginGeminiCli( // Notify caller with URL to open onAuth({ url: authUrl, - instructions: "Complete the sign-in in your browser. The callback will be captured automatically.", + instructions: "Complete the sign-in in your browser.", }); - // Wait for the callback + // Wait for the callback, racing with manual input if provided onProgress?.("Waiting for OAuth callback..."); - const { code, state } = await getCode(); - // Verify state matches - if (state !== verifier) { - throw new Error("OAuth state mismatch - possible CSRF attack"); + if (onManualCodeInput) { + // Race between browser callback and manual input + let manualInput: string | undefined; + let manualError: Error | undefined; + const manualPromise = onManualCodeInput() + .then((input) => { + manualInput = input; + server.cancelWait(); + }) + .catch((err) => { + manualError = err instanceof Error ? err : new Error(String(err)); + server.cancelWait(); + }); + + const result = await server.waitForCode(); + + // If manual input was cancelled, throw that error + if (manualError) { + throw manualError; + } + + if (result?.code) { + // Browser callback won - verify state + if (result.state !== verifier) { + throw new Error("OAuth state mismatch - possible CSRF attack"); + } + code = result.code; + } else if (manualInput) { + // Manual input won + const parsed = parseRedirectUrl(manualInput); + if (parsed.state && parsed.state !== verifier) { + throw new Error("OAuth state mismatch - possible CSRF attack"); + } + code = parsed.code; + } + + // If still no code, wait for manual promise and try that + if (!code) { + await manualPromise; + if (manualError) { + throw manualError; + } + if (manualInput) { + const parsed = parseRedirectUrl(manualInput); + if (parsed.state && parsed.state !== verifier) { + throw new Error("OAuth state mismatch - possible CSRF attack"); + } + code = parsed.code; + } + } + } else { + // Original flow: just wait for callback + const result = await server.waitForCode(); + if (result?.code) { + if (result.state !== verifier) { + throw new Error("OAuth state mismatch - possible CSRF attack"); + } + code = result.code; + } + } + + if (!code) { + throw new Error("No authorization code received"); } // Exchange code for tokens @@ -351,6 +439,6 @@ export async function loginGeminiCli( return credentials; } finally { - server.close(); + server.server.close(); } } diff --git a/packages/ai/src/utils/oauth/index.ts b/packages/ai/src/utils/oauth/index.ts index 808f0678..1f9ff1a2 100644 --- a/packages/ai/src/utils/oauth/index.ts +++ b/packages/ai/src/utils/oauth/index.ts @@ -133,6 +133,11 @@ export function getOAuthProviders(): OAuthProviderInfo[] { name: "Anthropic (Claude Pro/Max)", available: true, }, + { + id: "openai-codex", + name: "ChatGPT Plus/Pro (Codex Subscription)", + available: true, + }, { id: "github-copilot", name: "GitHub Copilot", @@ -148,10 +153,5 @@ export function getOAuthProviders(): OAuthProviderInfo[] { name: "Antigravity (Gemini 3, Claude, GPT-OSS)", available: true, }, - { - id: "openai-codex", - name: "ChatGPT Plus/Pro (Codex Subscription)", - available: true, - }, ]; } diff --git a/packages/ai/src/utils/oauth/openai-codex.ts b/packages/ai/src/utils/oauth/openai-codex.ts index 2e0bd5ce..b520229e 100644 --- a/packages/ai/src/utils/oauth/openai-codex.ts +++ b/packages/ai/src/utils/oauth/openai-codex.ts @@ -296,15 +296,25 @@ export async function loginOpenAICodex(options: { if (options.onManualCodeInput) { // Race between browser callback and manual input let manualCode: string | undefined; + let manualError: Error | undefined; const manualPromise = options .onManualCodeInput() .then((input) => { manualCode = input; server.cancelWait(); }) - .catch(() => {}); // Ignore rejection + .catch((err) => { + manualError = err instanceof Error ? err : new Error(String(err)); + server.cancelWait(); + }); const result = await server.waitForCode(); + + // If manual input was cancelled, throw that error + if (manualError) { + throw manualError; + } + if (result?.code) { // Browser callback won code = result.code; @@ -320,6 +330,9 @@ export async function loginOpenAICodex(options: { // If still no code, wait for manual promise to complete and try that if (!code) { await manualPromise; + if (manualError) { + throw manualError; + } if (manualCode) { const parsed = parseAuthorizationInput(manualCode); if (parsed.state && parsed.state !== state) { diff --git a/packages/coding-agent/src/core/auth-storage.ts b/packages/coding-agent/src/core/auth-storage.ts index 60f77392..8ac63dd7 100644 --- a/packages/coding-agent/src/core/auth-storage.ts +++ b/packages/coding-agent/src/core/auth-storage.ts @@ -163,6 +163,8 @@ export class AuthStorage { onProgress?: (message: string) => void; /** For providers with local callback servers (e.g., openai-codex), races with browser callback */ onManualCodeInput?: () => Promise; + /** For cancellation support (e.g., github-copilot polling) */ + signal?: AbortSignal; }, ): Promise { let credentials: OAuthCredentials; @@ -179,13 +181,14 @@ export class AuthStorage { onAuth: (url, instructions) => callbacks.onAuth({ url, instructions }), onPrompt: callbacks.onPrompt, onProgress: callbacks.onProgress, + signal: callbacks.signal, }); break; case "google-gemini-cli": - credentials = await loginGeminiCli(callbacks.onAuth, callbacks.onProgress); + credentials = await loginGeminiCli(callbacks.onAuth, callbacks.onProgress, callbacks.onManualCodeInput); break; case "google-antigravity": - credentials = await loginAntigravity(callbacks.onAuth, callbacks.onProgress); + credentials = await loginAntigravity(callbacks.onAuth, callbacks.onProgress, callbacks.onManualCodeInput); break; case "openai-codex": credentials = await loginOpenAICodex({ diff --git a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts new file mode 100644 index 00000000..369615b3 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts @@ -0,0 +1,161 @@ +import { getOAuthProviders } from "@mariozechner/pi-ai"; +import { Container, getEditorKeybindings, Input, Spacer, Text, type TUI } from "@mariozechner/pi-tui"; +import { exec } from "child_process"; +import { theme } from "../theme/theme.js"; +import { DynamicBorder } from "./dynamic-border.js"; + +/** + * Login dialog component - replaces editor during OAuth login flow + */ +export class LoginDialogComponent extends Container { + private contentContainer: Container; + private input: Input; + private tui: TUI; + private abortController = new AbortController(); + private inputResolver?: (value: string) => void; + private inputRejecter?: (error: Error) => void; + + constructor( + tui: TUI, + providerId: string, + private onComplete: (success: boolean, message?: string) => void, + ) { + super(); + this.tui = tui; + + const providerInfo = getOAuthProviders().find((p) => p.id === providerId); + const providerName = providerInfo?.name || providerId; + + // Top border + this.addChild(new DynamicBorder()); + + // Title + this.addChild(new Text(theme.fg("warning", `Login to ${providerName}`), 1, 0)); + + // Dynamic content area + this.contentContainer = new Container(); + this.addChild(this.contentContainer); + + // Input (always present, used when needed) + this.input = new Input(); + this.input.onSubmit = () => { + if (this.inputResolver) { + this.inputResolver(this.input.getValue()); + this.inputResolver = undefined; + this.inputRejecter = undefined; + } + }; + this.input.onEscape = () => { + this.cancel(); + }; + + // Bottom border + this.addChild(new DynamicBorder()); + } + + get signal(): AbortSignal { + return this.abortController.signal; + } + + private cancel(): void { + this.abortController.abort(); + if (this.inputRejecter) { + this.inputRejecter(new Error("Login cancelled")); + this.inputResolver = undefined; + this.inputRejecter = undefined; + } + this.onComplete(false, "Login cancelled"); + } + + /** + * Called by onAuth callback - show URL and optional instructions + */ + 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)); + + 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)); + + if (instructions) { + this.contentContainer.addChild(new Spacer(1)); + this.contentContainer.addChild(new Text(theme.fg("warning", instructions), 1, 0)); + } + + // Try to open browser + const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open"; + exec(`${openCmd} "${url}"`); + + this.tui.requestRender(); + } + + /** + * Show input for manual code/URL entry (for callback server providers) + */ + showManualInput(prompt: string): Promise { + this.contentContainer.addChild(new Spacer(1)); + this.contentContainer.addChild(new Text(theme.fg("dim", prompt), 1, 0)); + this.contentContainer.addChild(this.input); + this.contentContainer.addChild(new Text(theme.fg("dim", "(Escape to cancel)"), 1, 0)); + this.tui.requestRender(); + + return new Promise((resolve, reject) => { + this.inputResolver = resolve; + this.inputRejecter = reject; + }); + } + + /** + * Called by onPrompt callback - show prompt and wait for input + */ + showPrompt(message: string, placeholder?: string): Promise { + this.contentContainer.clear(); + this.contentContainer.addChild(new Spacer(1)); + this.contentContainer.addChild(new Text(theme.fg("text", message), 1, 0)); + if (placeholder) { + this.contentContainer.addChild(new Text(theme.fg("dim", `e.g., ${placeholder}`), 1, 0)); + } + this.contentContainer.addChild(this.input); + this.contentContainer.addChild(new Text(theme.fg("dim", "(Escape to cancel, Enter to submit)"), 1, 0)); + + this.input.setValue(""); + this.tui.requestRender(); + + return new Promise((resolve, reject) => { + this.inputResolver = resolve; + this.inputRejecter = reject; + }); + } + + /** + * Show waiting message (for polling flows like GitHub Copilot) + */ + showWaiting(message: string): void { + this.contentContainer.addChild(new Spacer(1)); + this.contentContainer.addChild(new Text(theme.fg("dim", message), 1, 0)); + this.contentContainer.addChild(new Text(theme.fg("dim", "(Escape to cancel)"), 1, 0)); + this.tui.requestRender(); + } + + /** + * Called by onProgress callback + */ + showProgress(message: string): void { + this.contentContainer.addChild(new Text(theme.fg("dim", message), 1, 0)); + this.tui.requestRender(); + } + + handleInput(data: string): void { + const kb = getEditorKeybindings(); + + if (kb.matches(data, "selectCancel")) { + this.cancel(); + return; + } + + // Pass to input + this.input.handleInput(data); + } +} diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 683be474..ade871e1 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -9,14 +9,13 @@ import * as os from "node:os"; import * as path from "node:path"; import Clipboard from "@crosscopy/clipboard"; import type { AgentMessage } from "@mariozechner/pi-agent-core"; -import type { AssistantMessage, Message, OAuthProvider } from "@mariozechner/pi-ai"; +import { type AssistantMessage, getOAuthProviders, type Message, type OAuthProvider } from "@mariozechner/pi-ai"; import type { KeyId, SlashCommand } from "@mariozechner/pi-tui"; import { CombinedAutocompleteProvider, type Component, Container, getEditorKeybindings, - Input, Loader, Markdown, matchesKey, @@ -27,7 +26,7 @@ import { TUI, visibleWidth, } from "@mariozechner/pi-tui"; -import { exec, spawn, spawnSync } from "child_process"; +import { spawn, spawnSync } from "child_process"; import { APP_NAME, getAuthPath, getDebugLogPath } from "../../config.js"; import type { AgentSession, AgentSessionEvent } from "../../core/agent-session.js"; import type { @@ -57,6 +56,7 @@ import { ExtensionEditorComponent } from "./components/extension-editor.js"; import { ExtensionInputComponent } from "./components/extension-input.js"; import { ExtensionSelectorComponent } from "./components/extension-selector.js"; import { FooterComponent } from "./components/footer.js"; +import { LoginDialogComponent } from "./components/login-dialog.js"; import { ModelSelectorComponent } from "./components/model-selector.js"; import { OAuthSelectorComponent } from "./components/oauth-selector.js"; import { SessionSelectorComponent } from "./components/session-selector.js"; @@ -2122,150 +2122,16 @@ export class InteractiveMode { done(); if (mode === "login") { - this.showStatus(`Logging in to ${providerId}...`); - - // For openai-codex: promise that resolves when user pastes code manually - let manualCodeResolve: ((code: string) => void) | undefined; - const manualCodePromise = new Promise((resolve) => { - manualCodeResolve = resolve; - }); - let manualCodeInput: Input | undefined; - - try { - - await this.session.modelRegistry.authStorage.login(providerId as OAuthProvider, { - onAuth: (info: { url: string; instructions?: string }) => { - this.chatContainer.addChild(new Spacer(1)); - - // OSC 8 hyperlink for desktop terminals that support clicking - const hyperlink = `\x1b]8;;${info.url}\x07Click here to login\x1b]8;;\x07`; - this.chatContainer.addChild(new Text(theme.fg("accent", hyperlink), 1, 0)); - - // OSC 52 to copy URL to clipboard (works over SSH, e.g., Termux) - const urlBase64 = Buffer.from(info.url).toString("base64"); - process.stdout.write(`\x1b]52;c;${urlBase64}\x07`); - this.chatContainer.addChild( - new Text(theme.fg("dim", "(URL copied to clipboard - paste in browser)"), 1, 0), - ); - - if (info.instructions) { - this.chatContainer.addChild(new Spacer(1)); - this.chatContainer.addChild(new Text(theme.fg("warning", info.instructions), 1, 0)); - } - - this.ui.requestRender(); - - // Try to open browser on desktop - const openCmd = - process.platform === "darwin" - ? "open" - : process.platform === "win32" - ? "start" - : "xdg-open"; - exec(`${openCmd} "${info.url}"`); - - // For openai-codex: show paste input immediately (races with browser callback) - if (providerId === "openai-codex") { - this.chatContainer.addChild(new Spacer(1)); - this.chatContainer.addChild( - new Text( - theme.fg("dim", "Alternatively, paste the authorization code or redirect URL below:"), - 1, - 0, - ), - ); - manualCodeInput = new Input(); - manualCodeInput.onSubmit = () => { - const code = manualCodeInput!.getValue(); - if (code && manualCodeResolve) { - manualCodeResolve(code); - manualCodeResolve = undefined; - } - }; - this.editorContainer.clear(); - this.editorContainer.addChild(manualCodeInput); - this.ui.setFocus(manualCodeInput); - this.ui.requestRender(); - } - }, - onPrompt: async (prompt: { message: string; placeholder?: string }) => { - // Clean up manual code input if it exists (fallback case) - if (manualCodeInput) { - this.editorContainer.clear(); - this.editorContainer.addChild(this.editor); - manualCodeInput = undefined; - } - - this.chatContainer.addChild(new Spacer(1)); - this.chatContainer.addChild(new Text(theme.fg("warning", prompt.message), 1, 0)); - if (prompt.placeholder) { - this.chatContainer.addChild(new Text(theme.fg("dim", prompt.placeholder), 1, 0)); - } - this.ui.requestRender(); - - return new Promise((resolve) => { - const codeInput = new Input(); - codeInput.onSubmit = () => { - const code = codeInput.getValue(); - this.editorContainer.clear(); - this.editorContainer.addChild(this.editor); - this.ui.setFocus(this.editor); - resolve(code); - }; - this.editorContainer.clear(); - this.editorContainer.addChild(codeInput); - this.ui.setFocus(codeInput); - this.ui.requestRender(); - }); - }, - onProgress: (message: string) => { - this.chatContainer.addChild(new Text(theme.fg("dim", message), 1, 0)); - this.ui.requestRender(); - }, - onManualCodeInput: () => manualCodePromise, - }); - - // Clean up manual code input if browser callback succeeded - if (manualCodeInput) { - this.editorContainer.clear(); - this.editorContainer.addChild(this.editor); - this.ui.setFocus(this.editor); - manualCodeInput = undefined; - } - - // Refresh models to pick up new baseUrl (e.g., github-copilot) - this.session.modelRegistry.refresh(); - this.chatContainer.addChild(new Spacer(1)); - this.chatContainer.addChild( - new Text(theme.fg("success", `✓ Successfully logged in to ${providerId}`), 1, 0), - ); - this.chatContainer.addChild( - new Text(theme.fg("dim", `Credentials saved to ${getAuthPath()}`), 1, 0), - ); - this.ui.requestRender(); - } catch (error: unknown) { - // Clean up manual code input on error - if (manualCodeInput) { - this.editorContainer.clear(); - this.editorContainer.addChild(this.editor); - this.ui.setFocus(this.editor); - manualCodeInput = undefined; - } - this.showError(`Login failed: ${error instanceof Error ? error.message : String(error)}`); - } + await this.showLoginDialog(providerId); } else { + // Logout flow + const providerInfo = getOAuthProviders().find((p) => p.id === providerId); + const providerName = providerInfo?.name || providerId; + try { this.session.modelRegistry.authStorage.logout(providerId); - // Refresh models to reset baseUrl this.session.modelRegistry.refresh(); - this.chatContainer.addChild(new Spacer(1)); - this.chatContainer.addChild( - new Text(theme.fg("success", `✓ Successfully logged out of ${providerId}`), 1, 0), - ); - this.chatContainer.addChild( - new Text(theme.fg("dim", `Credentials removed from ${getAuthPath()}`), 1, 0), - ); - this.ui.requestRender(); + this.showStatus(`Logged out of ${providerName}`); } catch (error: unknown) { this.showError(`Logout failed: ${error instanceof Error ? error.message : String(error)}`); } @@ -2280,6 +2146,95 @@ export class InteractiveMode { }); } + private async showLoginDialog(providerId: string): Promise { + const providerInfo = getOAuthProviders().find((p) => p.id === providerId); + const providerName = providerInfo?.name || providerId; + + // Providers that use callback servers (can paste redirect URL) + const usesCallbackServer = + providerId === "openai-codex" || providerId === "google-gemini-cli" || providerId === "google-antigravity"; + + // Create login dialog component + const dialog = new LoginDialogComponent(this.ui, providerId, (_success, _message) => { + // Completion handled below + }); + + // Show dialog in editor container + this.editorContainer.clear(); + this.editorContainer.addChild(dialog); + this.ui.setFocus(dialog); + this.ui.requestRender(); + + // Promise for manual code input (racing with callback server) + let manualCodeResolve: ((code: string) => void) | undefined; + let manualCodeReject: ((err: Error) => void) | undefined; + const manualCodePromise = new Promise((resolve, reject) => { + manualCodeResolve = resolve; + manualCodeReject = reject; + }); + + // Restore editor helper + const restoreEditor = () => { + this.editorContainer.clear(); + this.editorContainer.addChild(this.editor); + this.ui.setFocus(this.editor); + this.ui.requestRender(); + }; + + try { + await this.session.modelRegistry.authStorage.login(providerId as OAuthProvider, { + onAuth: (info: { url: string; instructions?: string }) => { + dialog.showAuth(info.url, info.instructions); + + if (usesCallbackServer) { + // Show input for manual paste, racing with callback + dialog + .showManualInput("Paste redirect URL below, or complete login in browser:") + .then((value) => { + if (value && manualCodeResolve) { + manualCodeResolve(value); + manualCodeResolve = undefined; + } + }) + .catch(() => { + if (manualCodeReject) { + manualCodeReject(new Error("Login cancelled")); + manualCodeReject = undefined; + } + }); + } else if (providerId === "github-copilot") { + // GitHub Copilot polls after onAuth + dialog.showWaiting("Waiting for browser authentication..."); + } + // For Anthropic: onPrompt is called immediately after + }, + + onPrompt: async (prompt: { message: string; placeholder?: string }) => { + return dialog.showPrompt(prompt.message, prompt.placeholder); + }, + + onProgress: (message: string) => { + dialog.showProgress(message); + }, + + onManualCodeInput: () => manualCodePromise, + + signal: dialog.signal, + }); + + // Success + restoreEditor(); + this.session.modelRegistry.refresh(); + this.showStatus(`Logged in to ${providerName}. Credentials saved to ${getAuthPath()}`); + } catch (error: unknown) { + restoreEditor(); + const errorMsg = error instanceof Error ? error.message : String(error); + if (errorMsg !== "Login cancelled") { + this.showError(`Failed to login to ${providerName}: ${errorMsg}`); + } + } + } + // ========================================================================= // Command handlers // ========================================================================= diff --git a/packages/tui/src/components/input.ts b/packages/tui/src/components/input.ts index ba4032a3..8ca7723a 100644 --- a/packages/tui/src/components/input.ts +++ b/packages/tui/src/components/input.ts @@ -11,6 +11,7 @@ export class Input implements Component { private value: string = ""; private cursor: number = 0; // Cursor position in the value public onSubmit?: (value: string) => void; + public onEscape?: () => void; // Bracketed paste mode buffering private pasteBuffer: string = ""; @@ -64,6 +65,12 @@ export class Input implements Component { } const kb = getEditorKeybindings(); + // Escape/Cancel + if (kb.matches(data, "selectCancel")) { + if (this.onEscape) this.onEscape(); + return; + } + // Submit if (kb.matches(data, "submit") || data === "\n") { if (this.onSubmit) this.onSubmit(this.value);