diff --git a/packages/coding-agent/src/modes/interactive/components/keybinding-hints.ts b/packages/coding-agent/src/modes/interactive/components/keybinding-hints.ts index 581961146f..c0e0db2552 100644 --- a/packages/coding-agent/src/modes/interactive/components/keybinding-hints.ts +++ b/packages/coding-agent/src/modes/interactive/components/keybinding-hints.ts @@ -32,9 +32,9 @@ function formatKeyPart(part: string, platform: NodeJS.Platform): string { const normalized = normalizeKeyPart(part); const arrow = formatArrowKey(normalized); if (arrow) return arrow; - if (platform === "darwin") { - if (normalized === "ctrl") return "Cmd"; - if (normalized === "alt") return "Option"; + // Terminals send the literal Control key on macOS, so never label it Cmd. + if (platform === "darwin" && normalized === "alt") { + return "Option"; } return normalized.charAt(0).toUpperCase() + normalized.slice(1); } diff --git a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts index 4e9e26193f..5fdbba2c2a 100644 --- a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts +++ b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts @@ -167,6 +167,13 @@ export class LoginDialogComponent extends Container implements Focusable { this.contentContainer.addChild(new Text(theme.fg("muted", keyHint("tui.select.cancel", "cancel")), 0, 0)); this.tui.requestRender(); + return this.waitForInput(); + } + + /** + * Wait for the next submission of the already-visible input. + */ + waitForInput(): Promise { return new Promise((resolve, reject) => { this.inputResolver = resolve; this.inputRejecter = reject; @@ -194,10 +201,7 @@ export class LoginDialogComponent extends Container implements Focusable { this.input.setValue(""); this.tui.requestRender(); - return new Promise((resolve, reject) => { - this.inputResolver = resolve; - this.inputRejecter = reject; - }); + return this.waitForInput(); } /** diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 7d40d1a7f9..99e6575214 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -6051,7 +6051,7 @@ export class InteractiveMode { if (providerOption.authType === "oauth") { resolve(await this.showLoginDialog(providerOption.id, providerOption.name)); } else if (providerOption.id === PRIME_INFERENCE_PROVIDER_ID) { - resolve(await this.showPrimeInferenceApiKeyLoginDialog()); + resolve(await this.showPrimeInferenceLoginDialog()); } else if (providerOption.id === BEDROCK_PROVIDER_ID) { resolve(await this.showBedrockSetupDialog(providerOption.id, providerOption.name)); } else { @@ -6292,6 +6292,30 @@ export class InteractiveMode { } } + private async completePrimeInferenceLogin( + apiKey: string, + dialog: LoginDialogComponent, + closeDialog: () => void, + ): Promise { + const previousPrimeCredential = this.modelRegistry.authStorage.get(PRIME_INFERENCE_PROVIDER_ID); + const previousPrimeTeam = + previousPrimeCredential?.type === "api_key" ? previousPrimeCredential.primeTeam : undefined; + this.modelRegistry.authStorage.set(PRIME_INFERENCE_PROVIDER_ID, { + type: "api_key", + key: apiKey, + ...(previousPrimeTeam !== undefined ? { primeTeam: previousPrimeTeam } : {}), + }); + const teamStatus = await this.selectPrimeInferenceTeam(apiKey, dialog); + + closeDialog(); + return await this.completeProviderAuthentication( + PRIME_INFERENCE_PROVIDER_ID, + PRIME_INFERENCE_PROVIDER_NAME, + "api_key", + teamStatus, + ); + } + private async showPrimeInferenceLoginDialog(): Promise { const dialog = new LoginDialogComponent( this.ui, @@ -6309,111 +6333,74 @@ export class InteractiveMode { this.ui.requestRender(); }; + // The browser challenge gets its own controller so a manually pasted key + // can stop the polling without tearing down the dialog. + const browserAbort = new AbortController(); + const onDialogAbort = () => browserAbort.abort(); + dialog.signal.addEventListener("abort", onDialogAbort, { once: true }); + + let resolveManualKey: (apiKey: string) => void = () => {}; + const manualKeyEntry = new Promise<{ apiKey: string; source: "manual" }>((resolve) => { + resolveManualKey = (apiKey) => resolve({ apiKey, source: "manual" }); + }); + try { - const result = await loginPrimeInference({ + const browserLogin = loginPrimeInference({ onAuth: (info) => { dialog.showAuth(info.url, info.instructions); - dialog.showWaiting("Waiting for browser authentication..."); + void (async () => { + let value = ( + await dialog.showManualInput("Complete the sign-in in your browser, or paste an API key below:") + ).trim(); + while (!value) { + value = (await dialog.waitForInput()).trim(); + } + resolveManualKey(value); + })().catch(() => { + // Cancellation surfaces through the dialog signal. + }); }, onProgress: (message) => { dialog.showProgress(message); }, - signal: dialog.signal, + signal: browserAbort.signal, }); + // Promise.race observes the rejection below, but keep a dedicated handler + // so an aborted browser flow can never surface as an unhandled rejection. + browserLogin.catch(() => {}); + const result = await Promise.race([browserLogin, manualKeyEntry]); if (dialog.signal.aborted) { closeDialog(); return { status: "cancelled" }; } - const previousPrimeCredential = this.modelRegistry.authStorage.get(PRIME_INFERENCE_PROVIDER_ID); - const previousPrimeTeam = - previousPrimeCredential?.type === "api_key" ? previousPrimeCredential.primeTeam : undefined; - this.modelRegistry.authStorage.set(PRIME_INFERENCE_PROVIDER_ID, { - type: "api_key", - key: result.apiKey, - ...(previousPrimeTeam !== undefined ? { primeTeam: previousPrimeTeam } : {}), - }); - const teamStatus = await this.selectPrimeInferenceTeam(result.apiKey, dialog); - - closeDialog(); - return await this.completeProviderAuthentication( - PRIME_INFERENCE_PROVIDER_ID, - PRIME_INFERENCE_PROVIDER_NAME, - "api_key", - teamStatus, - ); - } catch (error: unknown) { - closeDialog(); - const errorMsg = error instanceof Error ? error.message : String(error); - if (errorMsg !== "Login cancelled") { - this.showError(`Failed to login to ${PRIME_INFERENCE_PROVIDER_NAME}: ${errorMsg}`); - return { status: "failed" }; - } - return { status: "cancelled" }; - } - } - - private async showPrimeInferenceApiKeyLoginDialog(): Promise { - const dialog = new LoginDialogComponent( - this.ui, - PRIME_INFERENCE_PROVIDER_ID, - (_success, _message) => { - // Completion handled below - }, - PRIME_INFERENCE_PROVIDER_NAME, - ); - - const handle = this.showFullPaneOverlay(dialog, 88); - - const closeDialog = () => { - handle.hide(); - this.ui.requestRender(); - }; - - try { - const apiKey = (await dialog.showPrompt("Enter API key:")).trim(); - if (!apiKey) { - throw new Error("API key cannot be empty."); - } - - dialog.showProgress("Checking Prime Inference access..."); - const config = loadPrimeCliConfig(); - const access = await checkPrimeInferenceAccess(apiKey, config.baseUrl, { signal: dialog.signal }); - if (dialog.signal.aborted) { - closeDialog(); - return { status: "cancelled" }; - } - if (!access.ok) { - const status = access.status === undefined ? "" : `HTTP ${access.status}: `; - throw new Error(`Prime API key does not have Prime Inference access (${status}${access.message})`); + if (result.source === "manual") { + browserAbort.abort(); + dialog.showProgress("Checking Prime Inference access..."); + const config = loadPrimeCliConfig(); + const access = await checkPrimeInferenceAccess(result.apiKey, config.baseUrl, { signal: dialog.signal }); + if (dialog.signal.aborted) { + closeDialog(); + return { status: "cancelled" }; + } + if (!access.ok) { + const status = access.status === undefined ? "" : `HTTP ${access.status}: `; + throw new Error(`Prime API key does not have Prime Inference access (${status}${access.message})`); + } } - const previousPrimeCredential = this.modelRegistry.authStorage.get(PRIME_INFERENCE_PROVIDER_ID); - const previousPrimeTeam = - previousPrimeCredential?.type === "api_key" ? previousPrimeCredential.primeTeam : undefined; - this.modelRegistry.authStorage.set(PRIME_INFERENCE_PROVIDER_ID, { - type: "api_key", - key: apiKey, - ...(previousPrimeTeam !== undefined ? { primeTeam: previousPrimeTeam } : {}), - }); - const teamStatus = await this.selectPrimeInferenceTeam(apiKey, dialog); - - closeDialog(); - return await this.completeProviderAuthentication( - PRIME_INFERENCE_PROVIDER_ID, - PRIME_INFERENCE_PROVIDER_NAME, - "api_key", - teamStatus, - ); + return await this.completePrimeInferenceLogin(result.apiKey, dialog, closeDialog); } catch (error: unknown) { closeDialog(); const errorMsg = error instanceof Error ? error.message : String(error); if (errorMsg !== "Login cancelled") { - this.showError(`Failed to save API key for ${PRIME_INFERENCE_PROVIDER_NAME}: ${errorMsg}`); + this.showError(`Failed to login to ${PRIME_INFERENCE_PROVIDER_NAME}: ${errorMsg}`); return { status: "failed" }; } return { status: "cancelled" }; + } finally { + dialog.signal.removeEventListener("abort", onDialogAbort); } } diff --git a/packages/coding-agent/test/keybinding-hints.test.ts b/packages/coding-agent/test/keybinding-hints.test.ts index f714479111..5624c5c046 100644 --- a/packages/coding-agent/test/keybinding-hints.test.ts +++ b/packages/coding-agent/test/keybinding-hints.test.ts @@ -2,10 +2,10 @@ import { describe, expect, it } from "vitest"; import { formatKeyText } from "../src/modes/interactive/components/keybinding-hints.js"; describe("keybinding hint formatting", () => { - it("uses macOS modifier names on darwin", () => { - expect(formatKeyText("ctrl+p", "darwin")).toBe("Cmd+P"); + it("uses macOS modifier names on darwin but keeps Ctrl literal", () => { + expect(formatKeyText("ctrl+p", "darwin")).toBe("Ctrl+P"); expect(formatKeyText("alt+enter", "darwin")).toBe("Option+Enter"); - expect(formatKeyText("shift+ctrl+p/alt+up", "darwin")).toBe("Shift+Cmd+P/Option+↑"); + expect(formatKeyText("shift+ctrl+p/alt+up", "darwin")).toBe("Shift+Ctrl+P/Option+↑"); }); it("keeps canonical modifier names on linux", () => { diff --git a/packages/coding-agent/test/login-dialog.test.ts b/packages/coding-agent/test/login-dialog.test.ts index 0dc243899f..a231e03148 100644 --- a/packages/coding-agent/test/login-dialog.test.ts +++ b/packages/coding-agent/test/login-dialog.test.ts @@ -118,6 +118,30 @@ describe("LoginDialogComponent", () => { } }); + it("cancels the prompt with esc and ctrl+c", async () => { + for (const key of ["\x1b", "\x03"]) { + const dialog = new LoginDialogComponent(createFakeTui(), "prime-inference", () => {}, "Prime Inference"); + const prompt = dialog.showPrompt("Enter API key:"); + dialog.handleInput(key); + await expect(prompt).rejects.toThrow("Login cancelled"); + } + }); + + it("re-arms manual input after an empty submission", async () => { + const dialog = new LoginDialogComponent(createFakeTui(), "prime-inference", () => {}, "Prime Inference"); + dialog.showAuth("https://example.com/challenge", "Code: abc-123"); + + const first = dialog.showManualInput("Or paste an API key below:"); + dialog.handleInput("\r"); + await expect(first).resolves.toBe(""); + + const second = dialog.waitForInput(); + dialog.handleInput("p"); + dialog.handleInput("k"); + dialog.handleInput("\r"); + await expect(second).resolves.toBe("pk"); + }); + it("renders API key prompts without shell input markers", () => { const dialog = new LoginDialogComponent(createFakeTui(), "openai", () => {}, "OpenAI");