From 010e9acfe959f437613bcba7139b264012ca43a4 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 23 Apr 2026 13:42:09 +0200 Subject: [PATCH] feat(coding-agent): add searchable auth provider login flow (#3572) --- packages/coding-agent/docs/providers.md | 2 +- .../coding-agent/src/core/slash-commands.ts | 4 +- .../interactive/components/login-dialog.ts | 3 +- .../interactive/components/oauth-selector.ts | 108 ++++-- .../src/modes/interactive/interactive-mode.ts | 348 ++++++++++++++---- 5 files changed, 360 insertions(+), 105 deletions(-) diff --git a/packages/coding-agent/docs/providers.md b/packages/coding-agent/docs/providers.md index bcf4303f..16cf705e 100644 --- a/packages/coding-agent/docs/providers.md +++ b/packages/coding-agent/docs/providers.md @@ -44,7 +44,7 @@ Use `/logout` to clear credentials. Tokens are stored in `~/.pi/agent/auth.json` ### Environment Variables or Auth File -Set via environment variable: +Use `/login` in interactive mode and select a provider to store an API key in `auth.json`, or set credentials via environment variable: ```bash export ANTHROPIC_API_KEY=sk-ant-... diff --git a/packages/coding-agent/src/core/slash-commands.ts b/packages/coding-agent/src/core/slash-commands.ts index 9280f76c..ffff92bb 100644 --- a/packages/coding-agent/src/core/slash-commands.ts +++ b/packages/coding-agent/src/core/slash-commands.ts @@ -30,8 +30,8 @@ export const BUILTIN_SLASH_COMMANDS: ReadonlyArray = [ { name: "fork", description: "Create a new fork from a previous user message" }, { name: "clone", description: "Duplicate the current session at the current position" }, { name: "tree", description: "Navigate session tree (switch branches)" }, - { name: "login", description: "Login with OAuth provider" }, - { name: "logout", description: "Logout from OAuth provider" }, + { name: "login", description: "Configure provider authentication" }, + { name: "logout", description: "Remove provider authentication" }, { name: "new", description: "Start a new session" }, { name: "compact", description: "Manually compact the session context" }, { name: "resume", description: "Resume a different session" }, 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 28f1f07c..f44270ca 100644 --- a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts +++ b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts @@ -30,12 +30,13 @@ export class LoginDialogComponent extends Container implements Focusable { tui: TUI, providerId: string, private onComplete: (success: boolean, message?: string) => void, + providerNameOverride?: string, ) { super(); this.tui = tui; const providerInfo = getOAuthProviders().find((p) => p.id === providerId); - const providerName = providerInfo?.name || providerId; + const providerName = providerNameOverride || providerInfo?.name || providerId; // Top border this.addChild(new DynamicBorder()); diff --git a/packages/coding-agent/src/modes/interactive/components/oauth-selector.ts b/packages/coding-agent/src/modes/interactive/components/oauth-selector.ts index 767e16c0..c310f13a 100644 --- a/packages/coding-agent/src/modes/interactive/components/oauth-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/oauth-selector.ts @@ -1,16 +1,41 @@ -import type { OAuthProviderInterface } from "@mariozechner/pi-ai"; -import { getOAuthProviders } from "@mariozechner/pi-ai/oauth"; -import { Container, getKeybindings, Spacer, TruncatedText } from "@mariozechner/pi-tui"; +import { + Container, + type Focusable, + fuzzyFilter, + getKeybindings, + Input, + Spacer, + TruncatedText, +} from "@mariozechner/pi-tui"; import type { AuthStorage } from "../../../core/auth-storage.js"; import { theme } from "../theme/theme.js"; import { DynamicBorder } from "./dynamic-border.js"; +export type AuthSelectorProvider = { + id: string; + name: string; + authType: "oauth" | "api_key"; +}; + /** - * Component that renders an OAuth provider selector + * Component that renders an auth provider selector */ -export class OAuthSelectorComponent extends Container { +export class OAuthSelectorComponent extends Container implements Focusable { + private searchInput: Input; + + // Focusable implementation - propagate to search input for IME cursor positioning + private _focused = false; + get focused(): boolean { + return this._focused; + } + set focused(value: boolean) { + this._focused = value; + this.searchInput.focused = value; + } + private listContainer: Container; - private allProviders: OAuthProviderInterface[] = []; + private allProviders: AuthSelectorProvider[]; + private filteredProviders: AuthSelectorProvider[]; private selectedIndex: number = 0; private mode: "login" | "logout"; private authStorage: AuthStorage; @@ -20,6 +45,7 @@ export class OAuthSelectorComponent extends Container { constructor( mode: "login" | "logout", authStorage: AuthStorage, + providers: AuthSelectorProvider[], onSelect: (providerId: string) => void, onCancel: () => void, ) { @@ -27,21 +53,30 @@ export class OAuthSelectorComponent extends Container { this.mode = mode; this.authStorage = authStorage; + this.allProviders = providers; + this.filteredProviders = providers; this.onSelectCallback = onSelect; this.onCancelCallback = onCancel; - // Load all OAuth providers - this.loadProviders(); - // Add top border this.addChild(new DynamicBorder()); this.addChild(new Spacer(1)); // Add title - const title = mode === "login" ? "Select provider to login:" : "Select provider to logout:"; + const title = mode === "login" ? "Select provider to configure:" : "Select provider to logout:"; this.addChild(new TruncatedText(theme.bold(title))); this.addChild(new Spacer(1)); + this.searchInput = new Input(); + this.searchInput.onSubmit = () => { + const selectedProvider = this.filteredProviders[this.selectedIndex]; + if (selectedProvider) { + this.onSelectCallback(selectedProvider.id); + } + }; + this.addChild(this.searchInput); + this.addChild(new Spacer(1)); + // Create list container this.listContainer = new Container(); this.addChild(this.listContainer); @@ -52,27 +87,38 @@ export class OAuthSelectorComponent extends Container { this.addChild(new DynamicBorder()); // Initial render - this.updateList(); + this.filterProviders(""); } - private loadProviders(): void { - this.allProviders = getOAuthProviders(); + private filterProviders(query: string): void { + this.filteredProviders = query + ? fuzzyFilter(this.allProviders, query, (provider) => `${provider.name} ${provider.id} ${provider.authType}`) + : this.allProviders; + this.selectedIndex = Math.max(0, Math.min(this.selectedIndex, Math.max(0, this.filteredProviders.length - 1))); + this.updateList(); } private updateList(): void { this.listContainer.clear(); - for (let i = 0; i < this.allProviders.length; i++) { - const provider = this.allProviders[i]; + const maxVisible = 8; + const startIndex = Math.max( + 0, + Math.min(this.selectedIndex - Math.floor(maxVisible / 2), this.filteredProviders.length - maxVisible), + ); + const endIndex = Math.min(startIndex + maxVisible, this.filteredProviders.length); + + for (let i = startIndex; i < endIndex; i++) { + const provider = this.filteredProviders[i]; if (!provider) continue; const isSelected = i === this.selectedIndex; - // Check if user is logged in for this provider + // Check if user is configured for this provider const credentials = this.authStorage.get(provider.id); - const isLoggedIn = credentials?.type === "oauth"; - const statusIndicator = isLoggedIn ? theme.fg("success", " ✓ logged in") : ""; - + const statusIndicator = credentials + ? theme.fg("success", " ✓ configured") + : theme.fg("muted", " • unconfigured"); let line = ""; if (isSelected) { const prefix = theme.fg("accent", "→ "); @@ -86,10 +132,19 @@ export class OAuthSelectorComponent extends Container { this.listContainer.addChild(new TruncatedText(line, 0, 0)); } + if (startIndex > 0 || endIndex < this.filteredProviders.length) { + const scrollInfo = theme.fg("muted", ` (${this.selectedIndex + 1}/${this.filteredProviders.length})`); + this.listContainer.addChild(new TruncatedText(scrollInfo, 0, 0)); + } + // Show "no providers" if empty - if (this.allProviders.length === 0) { + if (this.filteredProviders.length === 0) { const message = - this.mode === "login" ? "No OAuth providers available" : "No OAuth providers logged in. Use /login first."; + this.allProviders.length === 0 + ? this.mode === "login" + ? "No providers available" + : "No providers logged in. Use /login first." + : "No matching providers"; this.listContainer.addChild(new TruncatedText(theme.fg("muted", ` ${message}`), 0, 0)); } } @@ -98,17 +153,19 @@ export class OAuthSelectorComponent extends Container { const kb = getKeybindings(); // Up arrow if (kb.matches(keyData, "tui.select.up")) { + if (this.filteredProviders.length === 0) return; this.selectedIndex = Math.max(0, this.selectedIndex - 1); this.updateList(); } // Down arrow else if (kb.matches(keyData, "tui.select.down")) { - this.selectedIndex = Math.min(this.allProviders.length - 1, this.selectedIndex + 1); + if (this.filteredProviders.length === 0) return; + this.selectedIndex = Math.min(this.filteredProviders.length - 1, this.selectedIndex + 1); this.updateList(); } // Enter else if (kb.matches(keyData, "tui.select.confirm")) { - const selectedProvider = this.allProviders[this.selectedIndex]; + const selectedProvider = this.filteredProviders[this.selectedIndex]; if (selectedProvider) { this.onSelectCallback(selectedProvider.id); } @@ -117,5 +174,10 @@ export class OAuthSelectorComponent extends Container { else if (kb.matches(keyData, "tui.select.cancel")) { this.onCancelCallback(); } + // Pass everything else to search input + else { + this.searchInput.handleInput(keyData); + this.filterProviders(this.searchInput.getValue()); + } } } diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index e67a2508..510de099 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -97,7 +97,7 @@ import { FooterComponent } from "./components/footer.js"; import { keyHint, keyText, rawKeyHint } from "./components/keybinding-hints.js"; import { LoginDialogComponent } from "./components/login-dialog.js"; import { ModelSelectorComponent } from "./components/model-selector.js"; -import { OAuthSelectorComponent } from "./components/oauth-selector.js"; +import { type AuthSelectorProvider, OAuthSelectorComponent } from "./components/oauth-selector.js"; import { ScopedModelsSelectorComponent } from "./components/scoped-models-selector.js"; import { SessionSelectorComponent } from "./components/session-selector.js"; import { SettingsSelectorComponent } from "./components/settings-selector.js"; @@ -168,6 +168,33 @@ function hasDefaultModelProvider(providerId: string): providerId is keyof typeof return providerId in defaultModelPerProvider; } +const API_KEY_PROVIDER_NAMES: Record = { + "azure-openai-responses": "Azure OpenAI Responses", + cerebras: "Cerebras", + fireworks: "Fireworks", + google: "Google Gemini", + "google-vertex": "Google Vertex AI", + groq: "Groq", + huggingface: "Hugging Face", + "kimi-coding": "Kimi For Coding", + mistral: "Mistral", + minimax: "MiniMax", + "minimax-cn": "MiniMax (China)", + opencode: "OpenCode Zen", + "opencode-go": "OpenCode Go", + openai: "OpenAI", + openrouter: "OpenRouter", + "vercel-ai-gateway": "Vercel AI Gateway", + xai: "xAI", + zai: "ZAI", +}; + +const API_KEY_LOGIN_PROVIDER_BLOCKLIST = new Set(["amazon-bedrock", "llama.cpp", "lmstudio", "ollama"]); + +function getApiKeyProviderDisplayName(providerId: string): string { + return API_KEY_PROVIDER_NAMES[providerId] ?? providerId; +} + /** * Options for InteractiveMode initialization. */ @@ -3906,7 +3933,7 @@ export class InteractiveMode { }, () => { done(); - this.ui.requestRender(); + this.showLoginAuthTypeSelector(); }, initialSearchInput, ); @@ -4259,42 +4286,149 @@ export class InteractiveMode { } } - private async showOAuthSelector(mode: "login" | "logout"): Promise { - if (mode === "logout") { - const providers = this.session.modelRegistry.authStorage.list(); - const loggedInProviders = providers.filter( - (p) => this.session.modelRegistry.authStorage.get(p)?.type === "oauth", - ); - if (loggedInProviders.length === 0) { - this.showStatus("No OAuth providers logged in. Use /login first."); - return; + private getLoginProviderOptions(authType?: "oauth" | "api_key"): AuthSelectorProvider[] { + const authStorage = this.session.modelRegistry.authStorage; + const oauthProviders = authStorage.getOAuthProviders(); + const oauthProviderIds = new Set(oauthProviders.map((provider) => provider.id)); + const options: AuthSelectorProvider[] = oauthProviders.map((provider) => ({ + id: provider.id, + name: provider.name, + authType: "oauth", + })); + + const modelProviders = new Set(this.session.modelRegistry.getAll().map((model) => model.provider)); + for (const providerId of modelProviders) { + if (oauthProviderIds.has(providerId) || API_KEY_LOGIN_PROVIDER_BLOCKLIST.has(providerId)) { + continue; } + options.push({ + id: providerId, + name: getApiKeyProviderDisplayName(providerId), + authType: "api_key", + }); + } + + const filteredOptions = authType ? options.filter((option) => option.authType === authType) : options; + return filteredOptions.sort((a, b) => a.name.localeCompare(b.name)); + } + + private getLogoutProviderOptions(): AuthSelectorProvider[] { + const authStorage = this.session.modelRegistry.authStorage; + const oauthNameById = new Map(authStorage.getOAuthProviders().map((provider) => [provider.id, provider.name])); + const options: AuthSelectorProvider[] = []; + + for (const providerId of authStorage.list()) { + const credential = authStorage.get(providerId); + if (!credential) { + continue; + } + options.push({ + id: providerId, + name: + credential.type === "oauth" + ? (oauthNameById.get(providerId) ?? providerId) + : getApiKeyProviderDisplayName(providerId), + authType: credential.type, + }); + } + + return options.sort((a, b) => a.name.localeCompare(b.name)); + } + + private showLoginAuthTypeSelector(): void { + const subscriptionLabel = "Use a subscription"; + const apiKeyLabel = "Use an API key"; + this.showSelector((done) => { + const selector = new ExtensionSelectorComponent( + "Select authentication method:", + [subscriptionLabel, apiKeyLabel], + (option) => { + done(); + const authType = option === subscriptionLabel ? "oauth" : "api_key"; + this.showLoginProviderSelector(authType); + }, + () => { + done(); + this.ui.requestRender(); + }, + ); + return { component: selector, focus: selector }; + }); + } + + private showLoginProviderSelector(authType: "oauth" | "api_key"): void { + const providerOptions = this.getLoginProviderOptions(authType); + if (providerOptions.length === 0) { + this.showStatus( + authType === "oauth" ? "No subscription providers available." : "No API key providers available.", + ); + return; + } + + this.showSelector((done) => { + const selector = new OAuthSelectorComponent( + "login", + this.session.modelRegistry.authStorage, + providerOptions, + async (providerId: string) => { + done(); + + const providerOption = providerOptions.find((provider) => provider.id === providerId); + if (!providerOption) { + return; + } + + if (providerOption.authType === "oauth") { + await this.showLoginDialog(providerOption.id, providerOption.name); + } else { + await this.showApiKeyLoginDialog(providerOption.id, providerOption.name); + } + }, + () => { + done(); + this.showLoginAuthTypeSelector(); + }, + ); + return { component: selector, focus: selector }; + }); + } + + private async showOAuthSelector(mode: "login" | "logout"): Promise { + if (mode === "login") { + this.showLoginAuthTypeSelector(); + return; + } + + const providerOptions = this.getLogoutProviderOptions(); + if (providerOptions.length === 0) { + this.showStatus("No providers logged in. Use /login first."); + return; } this.showSelector((done) => { const selector = new OAuthSelectorComponent( mode, this.session.modelRegistry.authStorage, + providerOptions, async (providerId: string) => { done(); - if (mode === "login") { - await this.showLoginDialog(providerId); - } else { - // Logout flow - const providerInfo = this.session.modelRegistry.authStorage - .getOAuthProviders() - .find((p) => p.id === providerId); - const providerName = providerInfo?.name || providerId; + const providerOption = providerOptions.find((provider) => provider.id === providerId); + if (!providerOption) { + return; + } - try { - this.session.modelRegistry.authStorage.logout(providerId); - this.session.modelRegistry.refresh(); - await this.updateAvailableProviderCount(); - this.showStatus(`Logged out of ${providerName}`); - } catch (error: unknown) { - this.showError(`Logout failed: ${error instanceof Error ? error.message : String(error)}`); - } + try { + this.session.modelRegistry.authStorage.logout(providerOption.id); + this.session.modelRegistry.refresh(); + await this.updateAvailableProviderCount(); + const message = + providerOption.authType === "oauth" + ? `Logged out of ${providerOption.name}` + : `Removed API key for ${providerOption.name}`; + this.showStatus(message); + } catch (error: unknown) { + this.showError(`Logout failed: ${error instanceof Error ? error.message : String(error)}`); } }, () => { @@ -4306,18 +4440,120 @@ export class InteractiveMode { }); } - private async showLoginDialog(providerId: string): Promise { - const providerInfo = this.session.modelRegistry.authStorage.getOAuthProviders().find((p) => p.id === providerId); - const providerName = providerInfo?.name || providerId; + private async completeProviderAuthentication( + providerId: string, + providerName: string, + authType: "oauth" | "api_key", + previousModel: Model | undefined, + ): Promise { + this.session.modelRegistry.refresh(); + + const actionLabel = authType === "oauth" ? `Logged in to ${providerName}` : `Saved API key for ${providerName}`; + + let selectedModel: Model | undefined; + let selectionError: string | undefined; + if (isUnknownModel(previousModel)) { + const availableModels = this.session.modelRegistry.getAvailable(); + const providerModels = availableModels.filter((model) => model.provider === providerId); + if (!hasDefaultModelProvider(providerId)) { + selectionError = `${actionLabel}, but no default model is configured for provider "${providerId}". Use /model to select a model.`; + } else if (providerModels.length === 0) { + selectionError = `${actionLabel}, but no models are available for that provider. Use /model to select a model.`; + } else { + const defaultModelId = defaultModelPerProvider[providerId]; + selectedModel = providerModels.find((model) => model.id === defaultModelId); + if (!selectedModel) { + selectionError = `${actionLabel}, but its default model "${defaultModelId}" is not available. Use /model to select a model.`; + } else { + try { + await this.session.setModel(selectedModel); + } catch (error: unknown) { + selectedModel = undefined; + const errorMessage = error instanceof Error ? error.message : String(error); + selectionError = `${actionLabel}, but selecting its default model failed: ${errorMessage}. Use /model to select a model.`; + } + } + } + } + + await this.updateAvailableProviderCount(); + this.footer.invalidate(); + this.updateEditorBorderColor(); + if (selectedModel) { + this.showStatus(`${actionLabel}. Selected ${selectedModel.id}. Credentials saved to ${getAuthPath()}`); + void this.maybeWarnAboutAnthropicSubscriptionAuth(selectedModel); + this.checkDaxnutsEasterEgg(selectedModel); + } else { + this.showStatus(`${actionLabel}. Credentials saved to ${getAuthPath()}`); + if (selectionError) { + this.showError(selectionError); + } else { + void this.maybeWarnAboutAnthropicSubscriptionAuth(); + } + } + } + + private async showApiKeyLoginDialog(providerId: string, providerName: string): Promise { + const previousModel = this.session.model; + + const dialog = new LoginDialogComponent( + this.ui, + providerId, + (_success, _message) => { + // Completion handled below + }, + providerName, + ); + + this.editorContainer.clear(); + this.editorContainer.addChild(dialog); + this.ui.setFocus(dialog); + this.ui.requestRender(); + + const restoreEditor = () => { + this.editorContainer.clear(); + this.editorContainer.addChild(this.editor); + this.ui.setFocus(this.editor); + this.ui.requestRender(); + }; + + try { + const apiKey = (await dialog.showPrompt("Enter API key:")).trim(); + if (!apiKey) { + throw new Error("API key cannot be empty."); + } + + this.session.modelRegistry.authStorage.set(providerId, { type: "api_key", key: apiKey }); + + restoreEditor(); + await this.completeProviderAuthentication(providerId, providerName, "api_key", previousModel); + } catch (error: unknown) { + restoreEditor(); + const errorMsg = error instanceof Error ? error.message : String(error); + if (errorMsg !== "Login cancelled") { + this.showError(`Failed to save API key for ${providerName}: ${errorMsg}`); + } + } + } + + private async showLoginDialog(providerId: string, providerName: string): Promise { + const providerInfo = this.session.modelRegistry.authStorage + .getOAuthProviders() + .find((provider) => provider.id === providerId); const previousModel = this.session.model; // Providers that use callback servers (can paste redirect URL) const usesCallbackServer = providerInfo?.usesCallbackServer ?? false; // Create login dialog component - const dialog = new LoginDialogComponent(this.ui, providerId, (_success, _message) => { - // Completion handled below - }); + const dialog = new LoginDialogComponent( + this.ui, + providerId, + (_success, _message) => { + // Completion handled below + }, + providerName, + ); // Show dialog in editor container this.editorContainer.clear(); @@ -4384,51 +4620,7 @@ export class InteractiveMode { // Success restoreEditor(); - this.session.modelRegistry.refresh(); - - let selectedModel: Model | undefined; - let selectionError: string | undefined; - if (isUnknownModel(previousModel)) { - const availableModels = this.session.modelRegistry.getAvailable(); - const providerModels = availableModels.filter((model) => model.provider === providerId); - if (!hasDefaultModelProvider(providerId)) { - selectionError = `Logged in to ${providerName}, but no default model is configured for provider "${providerId}". Use /model to select a model.`; - } else if (providerModels.length === 0) { - selectionError = `Logged in to ${providerName}, but no models are available for that provider. Use /model to select a model.`; - } else { - const defaultModelId = defaultModelPerProvider[providerId]; - selectedModel = providerModels.find((model) => model.id === defaultModelId); - if (!selectedModel) { - selectionError = `Logged in to ${providerName}, but its default model "${defaultModelId}" is not available. Use /model to select a model.`; - } else { - try { - await this.session.setModel(selectedModel); - } catch (error: unknown) { - selectedModel = undefined; - const errorMessage = error instanceof Error ? error.message : String(error); - selectionError = `Logged in to ${providerName}, but selecting its default model failed: ${errorMessage}. Use /model to select a model.`; - } - } - } - } - - await this.updateAvailableProviderCount(); - this.footer.invalidate(); - this.updateEditorBorderColor(); - if (selectedModel) { - this.showStatus( - `Logged in to ${providerName}. Selected ${selectedModel.id}. Credentials saved to ${getAuthPath()}`, - ); - void this.maybeWarnAboutAnthropicSubscriptionAuth(selectedModel); - this.checkDaxnutsEasterEgg(selectedModel); - } else { - this.showStatus(`Logged in to ${providerName}. Credentials saved to ${getAuthPath()}`); - if (selectionError) { - this.showError(selectionError); - } else { - void this.maybeWarnAboutAnthropicSubscriptionAuth(); - } - } + await this.completeProviderAuthentication(providerId, providerName, "oauth", previousModel); } catch (error: unknown) { restoreEditor(); const errorMsg = error instanceof Error ? error.message : String(error);