mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-21 16:01:05 +00:00
Improve OAuth login UI with consistent dialog component
- Add LoginDialogComponent with proper borders (top/bottom DynamicBorder) - Refactor all OAuth providers to use racing approach (browser callback vs manual paste) - Add onEscape handler to Input component for cancellation - Add abortable sleep for GitHub Copilot polling (instant cancel on Escape) - Show OS-specific click hint (Cmd+click on macOS, Ctrl+click elsewhere) - Clear content between login phases (fixes GitHub Copilot two-phase flow) - Use InteractiveMode's showStatus/showError for result messages - Reorder providers: Anthropic, ChatGPT, GitHub Copilot, Gemini CLI, Antigravity
This commit is contained in:
parent
05b9d55656
commit
9b12719ab1
9 changed files with 550 additions and 200 deletions
|
|
@ -136,17 +136,45 @@ async function startDeviceFlow(domain: string): Promise<DeviceCodeResponse> {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sleep that can be interrupted by an AbortSignal
|
||||||
|
*/
|
||||||
|
function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||||
|
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(
|
async function pollForGitHubAccessToken(
|
||||||
domain: string,
|
domain: string,
|
||||||
deviceCode: string,
|
deviceCode: string,
|
||||||
intervalSeconds: number,
|
intervalSeconds: number,
|
||||||
expiresIn: number,
|
expiresIn: number,
|
||||||
|
signal?: AbortSignal,
|
||||||
) {
|
) {
|
||||||
const urls = getUrls(domain);
|
const urls = getUrls(domain);
|
||||||
const deadline = Date.now() + expiresIn * 1000;
|
const deadline = Date.now() + expiresIn * 1000;
|
||||||
let intervalMs = Math.max(1000, Math.floor(intervalSeconds * 1000));
|
let intervalMs = Math.max(1000, Math.floor(intervalSeconds * 1000));
|
||||||
|
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
|
if (signal?.aborted) {
|
||||||
|
throw new Error("Login cancelled");
|
||||||
|
}
|
||||||
|
|
||||||
const raw = await fetchJson(urls.accessTokenUrl, {
|
const raw = await fetchJson(urls.accessTokenUrl, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -168,20 +196,20 @@ async function pollForGitHubAccessToken(
|
||||||
if (raw && typeof raw === "object" && typeof (raw as DeviceTokenErrorResponse).error === "string") {
|
if (raw && typeof raw === "object" && typeof (raw as DeviceTokenErrorResponse).error === "string") {
|
||||||
const err = (raw as DeviceTokenErrorResponse).error;
|
const err = (raw as DeviceTokenErrorResponse).error;
|
||||||
if (err === "authorization_pending") {
|
if (err === "authorization_pending") {
|
||||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
await abortableSleep(intervalMs, signal);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (err === "slow_down") {
|
if (err === "slow_down") {
|
||||||
intervalMs += 5000;
|
intervalMs += 5000;
|
||||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
await abortableSleep(intervalMs, signal);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`Device flow failed: ${err}`);
|
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");
|
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.onAuth - Callback with URL and optional instructions (user code)
|
||||||
* @param options.onPrompt - Callback to prompt user for input
|
* @param options.onPrompt - Callback to prompt user for input
|
||||||
* @param options.onProgress - Optional progress callback
|
* @param options.onProgress - Optional progress callback
|
||||||
|
* @param options.signal - Optional AbortSignal for cancellation
|
||||||
*/
|
*/
|
||||||
export async function loginGitHubCopilot(options: {
|
export async function loginGitHubCopilot(options: {
|
||||||
onAuth: (url: string, instructions?: string) => void;
|
onAuth: (url: string, instructions?: string) => void;
|
||||||
onPrompt: (prompt: { message: string; placeholder?: string; allowEmpty?: boolean }) => Promise<string>;
|
onPrompt: (prompt: { message: string; placeholder?: string; allowEmpty?: boolean }) => Promise<string>;
|
||||||
onProgress?: (message: string) => void;
|
onProgress?: (message: string) => void;
|
||||||
|
signal?: AbortSignal;
|
||||||
}): Promise<OAuthCredentials> {
|
}): Promise<OAuthCredentials> {
|
||||||
const input = await options.onPrompt({
|
const input = await options.onPrompt({
|
||||||
message: "GitHub Enterprise URL/domain (blank for github.com)",
|
message: "GitHub Enterprise URL/domain (blank for github.com)",
|
||||||
|
|
@ -286,6 +316,10 @@ export async function loginGitHubCopilot(options: {
|
||||||
allowEmpty: true,
|
allowEmpty: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (options.signal?.aborted) {
|
||||||
|
throw new Error("Login cancelled");
|
||||||
|
}
|
||||||
|
|
||||||
const trimmed = input.trim();
|
const trimmed = input.trim();
|
||||||
const enterpriseDomain = normalizeDomain(input);
|
const enterpriseDomain = normalizeDomain(input);
|
||||||
if (trimmed && !enterpriseDomain) {
|
if (trimmed && !enterpriseDomain) {
|
||||||
|
|
@ -301,6 +335,7 @@ export async function loginGitHubCopilot(options: {
|
||||||
device.device_code,
|
device.device_code,
|
||||||
device.interval,
|
device.interval,
|
||||||
device.expires_in,
|
device.expires_in,
|
||||||
|
options.signal,
|
||||||
);
|
);
|
||||||
const credentials = await refreshGitHubCopilotToken(githubAccessToken, enterpriseDomain ?? undefined);
|
const credentials = await refreshGitHubCopilotToken(githubAccessToken, enterpriseDomain ?? undefined);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,23 +33,21 @@ const TOKEN_URL = "https://oauth2.googleapis.com/token";
|
||||||
// Fallback project ID when discovery fails
|
// Fallback project ID when discovery fails
|
||||||
const DEFAULT_PROJECT_ID = "rising-fact-p41fc";
|
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
|
* Start a local HTTP server to receive the OAuth callback
|
||||||
*/
|
*/
|
||||||
async function startCallbackServer(): Promise<{
|
async function startCallbackServer(): Promise<CallbackServerInfo> {
|
||||||
server: Server;
|
|
||||||
getCode: () => Promise<{ code: string; state: string }>;
|
|
||||||
}> {
|
|
||||||
const { createServer } = await import("http");
|
const { createServer } = await import("http");
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let codeResolve: (value: { code: string; state: string }) => void;
|
let result: { code: string; state: string } | null = null;
|
||||||
let codeReject: (error: Error) => void;
|
let cancelled = false;
|
||||||
|
|
||||||
const codePromise = new Promise<{ code: string; state: string }>((res, rej) => {
|
|
||||||
codeResolve = res;
|
|
||||||
codeReject = rej;
|
|
||||||
});
|
|
||||||
|
|
||||||
const server = createServer((req, res) => {
|
const server = createServer((req, res) => {
|
||||||
const url = new URL(req.url || "", `http://localhost:51121`);
|
const url = new URL(req.url || "", `http://localhost:51121`);
|
||||||
|
|
@ -64,7 +62,6 @@ async function startCallbackServer(): Promise<{
|
||||||
res.end(
|
res.end(
|
||||||
`<html><body><h1>Authentication Failed</h1><p>Error: ${error}</p><p>You can close this window.</p></body></html>`,
|
`<html><body><h1>Authentication Failed</h1><p>Error: ${error}</p><p>You can close this window.</p></body></html>`,
|
||||||
);
|
);
|
||||||
codeReject(new Error(`OAuth error: ${error}`));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -73,13 +70,12 @@ async function startCallbackServer(): Promise<{
|
||||||
res.end(
|
res.end(
|
||||||
`<html><body><h1>Authentication Successful</h1><p>You can close this window and return to the terminal.</p></body></html>`,
|
`<html><body><h1>Authentication Successful</h1><p>You can close this window and return to the terminal.</p></body></html>`,
|
||||||
);
|
);
|
||||||
codeResolve({ code, state });
|
result = { code, state };
|
||||||
} else {
|
} else {
|
||||||
res.writeHead(400, { "Content-Type": "text/html" });
|
res.writeHead(400, { "Content-Type": "text/html" });
|
||||||
res.end(
|
res.end(
|
||||||
`<html><body><h1>Authentication Failed</h1><p>Missing code or state parameter.</p></body></html>`,
|
`<html><body><h1>Authentication Failed</h1><p>Missing code or state parameter.</p></body></html>`,
|
||||||
);
|
);
|
||||||
codeReject(new Error("Missing code or state in callback"));
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
res.writeHead(404);
|
res.writeHead(404);
|
||||||
|
|
@ -94,12 +90,40 @@ async function startCallbackServer(): Promise<{
|
||||||
server.listen(51121, "127.0.0.1", () => {
|
server.listen(51121, "127.0.0.1", () => {
|
||||||
resolve({
|
resolve({
|
||||||
server,
|
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 {
|
interface LoadCodeAssistPayload {
|
||||||
cloudaicompanionProject?: string | { id?: string };
|
cloudaicompanionProject?: string | { id?: string };
|
||||||
currentTier?: { 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 onAuth - Callback with URL and optional instructions
|
||||||
* @param onProgress - Optional progress callback
|
* @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(
|
export async function loginAntigravity(
|
||||||
onAuth: (info: { url: string; instructions?: string }) => void,
|
onAuth: (info: { url: string; instructions?: string }) => void,
|
||||||
onProgress?: (message: string) => void,
|
onProgress?: (message: string) => void,
|
||||||
|
onManualCodeInput?: () => Promise<string>,
|
||||||
): Promise<OAuthCredentials> {
|
): Promise<OAuthCredentials> {
|
||||||
const { verifier, challenge } = await generatePKCE();
|
const { verifier, challenge } = await generatePKCE();
|
||||||
|
|
||||||
// Start local server for callback
|
// Start local server for callback
|
||||||
onProgress?.("Starting local server for OAuth callback...");
|
onProgress?.("Starting local server for OAuth callback...");
|
||||||
const { server, getCode } = await startCallbackServer();
|
const server = await startCallbackServer();
|
||||||
|
|
||||||
|
let code: string | undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Build authorization URL
|
// Build authorization URL
|
||||||
|
|
@ -256,17 +285,76 @@ export async function loginAntigravity(
|
||||||
// Notify caller with URL to open
|
// Notify caller with URL to open
|
||||||
onAuth({
|
onAuth({
|
||||||
url: authUrl,
|
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...");
|
onProgress?.("Waiting for OAuth callback...");
|
||||||
const { code, state } = await getCode();
|
|
||||||
|
|
||||||
// Verify state matches
|
if (onManualCodeInput) {
|
||||||
if (state !== verifier) {
|
// 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");
|
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
|
// Exchange code for tokens
|
||||||
onProgress?.("Exchanging authorization code for tokens...");
|
onProgress?.("Exchanging authorization code for tokens...");
|
||||||
|
|
@ -320,6 +408,6 @@ export async function loginAntigravity(
|
||||||
|
|
||||||
return credentials;
|
return credentials;
|
||||||
} finally {
|
} finally {
|
||||||
server.close();
|
server.server.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,23 +25,21 @@ const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
||||||
const TOKEN_URL = "https://oauth2.googleapis.com/token";
|
const TOKEN_URL = "https://oauth2.googleapis.com/token";
|
||||||
const CODE_ASSIST_ENDPOINT = "https://cloudcode-pa.googleapis.com";
|
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
|
* Start a local HTTP server to receive the OAuth callback
|
||||||
*/
|
*/
|
||||||
async function startCallbackServer(): Promise<{
|
async function startCallbackServer(): Promise<CallbackServerInfo> {
|
||||||
server: Server;
|
|
||||||
getCode: () => Promise<{ code: string; state: string }>;
|
|
||||||
}> {
|
|
||||||
const { createServer } = await import("http");
|
const { createServer } = await import("http");
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let codeResolve: (value: { code: string; state: string }) => void;
|
let result: { code: string; state: string } | null = null;
|
||||||
let codeReject: (error: Error) => void;
|
let cancelled = false;
|
||||||
|
|
||||||
const codePromise = new Promise<{ code: string; state: string }>((res, rej) => {
|
|
||||||
codeResolve = res;
|
|
||||||
codeReject = rej;
|
|
||||||
});
|
|
||||||
|
|
||||||
const server = createServer((req, res) => {
|
const server = createServer((req, res) => {
|
||||||
const url = new URL(req.url || "", `http://localhost:8085`);
|
const url = new URL(req.url || "", `http://localhost:8085`);
|
||||||
|
|
@ -56,7 +54,6 @@ async function startCallbackServer(): Promise<{
|
||||||
res.end(
|
res.end(
|
||||||
`<html><body><h1>Authentication Failed</h1><p>Error: ${error}</p><p>You can close this window.</p></body></html>`,
|
`<html><body><h1>Authentication Failed</h1><p>Error: ${error}</p><p>You can close this window.</p></body></html>`,
|
||||||
);
|
);
|
||||||
codeReject(new Error(`OAuth error: ${error}`));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -65,13 +62,12 @@ async function startCallbackServer(): Promise<{
|
||||||
res.end(
|
res.end(
|
||||||
`<html><body><h1>Authentication Successful</h1><p>You can close this window and return to the terminal.</p></body></html>`,
|
`<html><body><h1>Authentication Successful</h1><p>You can close this window and return to the terminal.</p></body></html>`,
|
||||||
);
|
);
|
||||||
codeResolve({ code, state });
|
result = { code, state };
|
||||||
} else {
|
} else {
|
||||||
res.writeHead(400, { "Content-Type": "text/html" });
|
res.writeHead(400, { "Content-Type": "text/html" });
|
||||||
res.end(
|
res.end(
|
||||||
`<html><body><h1>Authentication Failed</h1><p>Missing code or state parameter.</p></body></html>`,
|
`<html><body><h1>Authentication Failed</h1><p>Missing code or state parameter.</p></body></html>`,
|
||||||
);
|
);
|
||||||
codeReject(new Error("Missing code or state in callback"));
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
res.writeHead(404);
|
res.writeHead(404);
|
||||||
|
|
@ -86,12 +82,40 @@ async function startCallbackServer(): Promise<{
|
||||||
server.listen(8085, "127.0.0.1", () => {
|
server.listen(8085, "127.0.0.1", () => {
|
||||||
resolve({
|
resolve({
|
||||||
server,
|
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 {
|
interface LoadCodeAssistPayload {
|
||||||
cloudaicompanionProject?: string;
|
cloudaicompanionProject?: string;
|
||||||
currentTier?: { id?: 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 onAuth - Callback with URL and optional instructions
|
||||||
* @param onProgress - Optional progress callback
|
* @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(
|
export async function loginGeminiCli(
|
||||||
onAuth: (info: { url: string; instructions?: string }) => void,
|
onAuth: (info: { url: string; instructions?: string }) => void,
|
||||||
onProgress?: (message: string) => void,
|
onProgress?: (message: string) => void,
|
||||||
|
onManualCodeInput?: () => Promise<string>,
|
||||||
): Promise<OAuthCredentials> {
|
): Promise<OAuthCredentials> {
|
||||||
const { verifier, challenge } = await generatePKCE();
|
const { verifier, challenge } = await generatePKCE();
|
||||||
|
|
||||||
// Start local server for callback
|
// Start local server for callback
|
||||||
onProgress?.("Starting local server for OAuth callback...");
|
onProgress?.("Starting local server for OAuth callback...");
|
||||||
const { server, getCode } = await startCallbackServer();
|
const server = await startCallbackServer();
|
||||||
|
|
||||||
|
let code: string | undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Build authorization URL
|
// Build authorization URL
|
||||||
|
|
@ -287,17 +316,76 @@ export async function loginGeminiCli(
|
||||||
// Notify caller with URL to open
|
// Notify caller with URL to open
|
||||||
onAuth({
|
onAuth({
|
||||||
url: authUrl,
|
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...");
|
onProgress?.("Waiting for OAuth callback...");
|
||||||
const { code, state } = await getCode();
|
|
||||||
|
|
||||||
// Verify state matches
|
if (onManualCodeInput) {
|
||||||
if (state !== verifier) {
|
// 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");
|
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
|
// Exchange code for tokens
|
||||||
onProgress?.("Exchanging authorization code for tokens...");
|
onProgress?.("Exchanging authorization code for tokens...");
|
||||||
|
|
@ -351,6 +439,6 @@ export async function loginGeminiCli(
|
||||||
|
|
||||||
return credentials;
|
return credentials;
|
||||||
} finally {
|
} finally {
|
||||||
server.close();
|
server.server.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,11 @@ export function getOAuthProviders(): OAuthProviderInfo[] {
|
||||||
name: "Anthropic (Claude Pro/Max)",
|
name: "Anthropic (Claude Pro/Max)",
|
||||||
available: true,
|
available: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "openai-codex",
|
||||||
|
name: "ChatGPT Plus/Pro (Codex Subscription)",
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "github-copilot",
|
id: "github-copilot",
|
||||||
name: "GitHub Copilot",
|
name: "GitHub Copilot",
|
||||||
|
|
@ -148,10 +153,5 @@ export function getOAuthProviders(): OAuthProviderInfo[] {
|
||||||
name: "Antigravity (Gemini 3, Claude, GPT-OSS)",
|
name: "Antigravity (Gemini 3, Claude, GPT-OSS)",
|
||||||
available: true,
|
available: true,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "openai-codex",
|
|
||||||
name: "ChatGPT Plus/Pro (Codex Subscription)",
|
|
||||||
available: true,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -296,15 +296,25 @@ export async function loginOpenAICodex(options: {
|
||||||
if (options.onManualCodeInput) {
|
if (options.onManualCodeInput) {
|
||||||
// Race between browser callback and manual input
|
// Race between browser callback and manual input
|
||||||
let manualCode: string | undefined;
|
let manualCode: string | undefined;
|
||||||
|
let manualError: Error | undefined;
|
||||||
const manualPromise = options
|
const manualPromise = options
|
||||||
.onManualCodeInput()
|
.onManualCodeInput()
|
||||||
.then((input) => {
|
.then((input) => {
|
||||||
manualCode = input;
|
manualCode = input;
|
||||||
server.cancelWait();
|
server.cancelWait();
|
||||||
})
|
})
|
||||||
.catch(() => {}); // Ignore rejection
|
.catch((err) => {
|
||||||
|
manualError = err instanceof Error ? err : new Error(String(err));
|
||||||
|
server.cancelWait();
|
||||||
|
});
|
||||||
|
|
||||||
const result = await server.waitForCode();
|
const result = await server.waitForCode();
|
||||||
|
|
||||||
|
// If manual input was cancelled, throw that error
|
||||||
|
if (manualError) {
|
||||||
|
throw manualError;
|
||||||
|
}
|
||||||
|
|
||||||
if (result?.code) {
|
if (result?.code) {
|
||||||
// Browser callback won
|
// Browser callback won
|
||||||
code = result.code;
|
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 still no code, wait for manual promise to complete and try that
|
||||||
if (!code) {
|
if (!code) {
|
||||||
await manualPromise;
|
await manualPromise;
|
||||||
|
if (manualError) {
|
||||||
|
throw manualError;
|
||||||
|
}
|
||||||
if (manualCode) {
|
if (manualCode) {
|
||||||
const parsed = parseAuthorizationInput(manualCode);
|
const parsed = parseAuthorizationInput(manualCode);
|
||||||
if (parsed.state && parsed.state !== state) {
|
if (parsed.state && parsed.state !== state) {
|
||||||
|
|
|
||||||
|
|
@ -163,6 +163,8 @@ export class AuthStorage {
|
||||||
onProgress?: (message: string) => void;
|
onProgress?: (message: string) => void;
|
||||||
/** For providers with local callback servers (e.g., openai-codex), races with browser callback */
|
/** For providers with local callback servers (e.g., openai-codex), races with browser callback */
|
||||||
onManualCodeInput?: () => Promise<string>;
|
onManualCodeInput?: () => Promise<string>;
|
||||||
|
/** For cancellation support (e.g., github-copilot polling) */
|
||||||
|
signal?: AbortSignal;
|
||||||
},
|
},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
let credentials: OAuthCredentials;
|
let credentials: OAuthCredentials;
|
||||||
|
|
@ -179,13 +181,14 @@ export class AuthStorage {
|
||||||
onAuth: (url, instructions) => callbacks.onAuth({ url, instructions }),
|
onAuth: (url, instructions) => callbacks.onAuth({ url, instructions }),
|
||||||
onPrompt: callbacks.onPrompt,
|
onPrompt: callbacks.onPrompt,
|
||||||
onProgress: callbacks.onProgress,
|
onProgress: callbacks.onProgress,
|
||||||
|
signal: callbacks.signal,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case "google-gemini-cli":
|
case "google-gemini-cli":
|
||||||
credentials = await loginGeminiCli(callbacks.onAuth, callbacks.onProgress);
|
credentials = await loginGeminiCli(callbacks.onAuth, callbacks.onProgress, callbacks.onManualCodeInput);
|
||||||
break;
|
break;
|
||||||
case "google-antigravity":
|
case "google-antigravity":
|
||||||
credentials = await loginAntigravity(callbacks.onAuth, callbacks.onProgress);
|
credentials = await loginAntigravity(callbacks.onAuth, callbacks.onProgress, callbacks.onManualCodeInput);
|
||||||
break;
|
break;
|
||||||
case "openai-codex":
|
case "openai-codex":
|
||||||
credentials = await loginOpenAICodex({
|
credentials = await loginOpenAICodex({
|
||||||
|
|
|
||||||
|
|
@ -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<string> {
|
||||||
|
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<string> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,14 +9,13 @@ import * as os from "node:os";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import Clipboard from "@crosscopy/clipboard";
|
import Clipboard from "@crosscopy/clipboard";
|
||||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
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 type { KeyId, SlashCommand } from "@mariozechner/pi-tui";
|
||||||
import {
|
import {
|
||||||
CombinedAutocompleteProvider,
|
CombinedAutocompleteProvider,
|
||||||
type Component,
|
type Component,
|
||||||
Container,
|
Container,
|
||||||
getEditorKeybindings,
|
getEditorKeybindings,
|
||||||
Input,
|
|
||||||
Loader,
|
Loader,
|
||||||
Markdown,
|
Markdown,
|
||||||
matchesKey,
|
matchesKey,
|
||||||
|
|
@ -27,7 +26,7 @@ import {
|
||||||
TUI,
|
TUI,
|
||||||
visibleWidth,
|
visibleWidth,
|
||||||
} from "@mariozechner/pi-tui";
|
} 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 { APP_NAME, getAuthPath, getDebugLogPath } from "../../config.js";
|
||||||
import type { AgentSession, AgentSessionEvent } from "../../core/agent-session.js";
|
import type { AgentSession, AgentSessionEvent } from "../../core/agent-session.js";
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -57,6 +56,7 @@ import { ExtensionEditorComponent } from "./components/extension-editor.js";
|
||||||
import { ExtensionInputComponent } from "./components/extension-input.js";
|
import { ExtensionInputComponent } from "./components/extension-input.js";
|
||||||
import { ExtensionSelectorComponent } from "./components/extension-selector.js";
|
import { ExtensionSelectorComponent } from "./components/extension-selector.js";
|
||||||
import { FooterComponent } from "./components/footer.js";
|
import { FooterComponent } from "./components/footer.js";
|
||||||
|
import { LoginDialogComponent } from "./components/login-dialog.js";
|
||||||
import { ModelSelectorComponent } from "./components/model-selector.js";
|
import { ModelSelectorComponent } from "./components/model-selector.js";
|
||||||
import { OAuthSelectorComponent } from "./components/oauth-selector.js";
|
import { OAuthSelectorComponent } from "./components/oauth-selector.js";
|
||||||
import { SessionSelectorComponent } from "./components/session-selector.js";
|
import { SessionSelectorComponent } from "./components/session-selector.js";
|
||||||
|
|
@ -2122,150 +2122,16 @@ export class InteractiveMode {
|
||||||
done();
|
done();
|
||||||
|
|
||||||
if (mode === "login") {
|
if (mode === "login") {
|
||||||
this.showStatus(`Logging in to ${providerId}...`);
|
await this.showLoginDialog(providerId);
|
||||||
|
|
||||||
// For openai-codex: promise that resolves when user pastes code manually
|
|
||||||
let manualCodeResolve: ((code: string) => void) | undefined;
|
|
||||||
const manualCodePromise = new Promise<string>((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<string>((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)}`);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
|
// Logout flow
|
||||||
|
const providerInfo = getOAuthProviders().find((p) => p.id === providerId);
|
||||||
|
const providerName = providerInfo?.name || providerId;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.session.modelRegistry.authStorage.logout(providerId);
|
this.session.modelRegistry.authStorage.logout(providerId);
|
||||||
// Refresh models to reset baseUrl
|
|
||||||
this.session.modelRegistry.refresh();
|
this.session.modelRegistry.refresh();
|
||||||
this.chatContainer.addChild(new Spacer(1));
|
this.showStatus(`Logged out of ${providerName}`);
|
||||||
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();
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
this.showError(`Logout failed: ${error instanceof Error ? error.message : String(error)}`);
|
this.showError(`Logout failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
}
|
}
|
||||||
|
|
@ -2280,6 +2146,95 @@ export class InteractiveMode {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async showLoginDialog(providerId: string): Promise<void> {
|
||||||
|
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<string>((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
|
// Command handlers
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ export class Input implements Component {
|
||||||
private value: string = "";
|
private value: string = "";
|
||||||
private cursor: number = 0; // Cursor position in the value
|
private cursor: number = 0; // Cursor position in the value
|
||||||
public onSubmit?: (value: string) => void;
|
public onSubmit?: (value: string) => void;
|
||||||
|
public onEscape?: () => void;
|
||||||
|
|
||||||
// Bracketed paste mode buffering
|
// Bracketed paste mode buffering
|
||||||
private pasteBuffer: string = "";
|
private pasteBuffer: string = "";
|
||||||
|
|
@ -64,6 +65,12 @@ export class Input implements Component {
|
||||||
}
|
}
|
||||||
const kb = getEditorKeybindings();
|
const kb = getEditorKeybindings();
|
||||||
|
|
||||||
|
// Escape/Cancel
|
||||||
|
if (kb.matches(data, "selectCancel")) {
|
||||||
|
if (this.onEscape) this.onEscape();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Submit
|
// Submit
|
||||||
if (kb.matches(data, "submit") || data === "\n") {
|
if (kb.matches(data, "submit") || data === "\n") {
|
||||||
if (this.onSubmit) this.onSubmit(this.value);
|
if (this.onSubmit) this.onSubmit(this.value);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue