diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 0752db171..e5c2333ee 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Added `/login ` support with provider autocomplete. - Added public SDK exports for CLI-equivalent model and scoped-model resolution ([#6201](https://github.com/earendil-works/pi/issues/6201)). - Added extension entry renderers for persisted display-only session entries that are rendered in interactive mode without being sent to the model context. diff --git a/packages/coding-agent/src/core/slash-commands.ts b/packages/coding-agent/src/core/slash-commands.ts index 77dd79aad..fcbf1ea7f 100644 --- a/packages/coding-agent/src/core/slash-commands.ts +++ b/packages/coding-agent/src/core/slash-commands.ts @@ -13,11 +13,12 @@ export interface SlashCommandInfo { export interface BuiltinSlashCommand { name: string; description: string; + argumentHint?: string; } export const BUILTIN_SLASH_COMMANDS: ReadonlyArray = [ { name: "settings", description: "Open settings menu" }, - { name: "model", description: "Select model (opens selector UI)" }, + { name: "model", description: "Select model (opens selector UI)", argumentHint: "" }, { name: "scoped-models", description: "Enable/disable models for Ctrl+P cycling" }, { name: "export", description: "Export session (HTML default, or specify path: .html/.jsonl)" }, { name: "import", description: "Import and resume a session from a JSONL file" }, @@ -31,7 +32,7 @@ export const BUILTIN_SLASH_COMMANDS: ReadonlyArray = [ { name: "clone", description: "Duplicate the current session at the current position" }, { name: "tree", description: "Navigate session tree (switch branches)" }, { name: "trust", description: "Save project trust decision for future sessions" }, - { name: "login", description: "Configure provider authentication" }, + { name: "login", description: "Configure provider authentication", argumentHint: "" }, { name: "logout", description: "Remove provider authentication" }, { name: "new", description: "Start a new session" }, { name: "compact", description: "Manually compact the session context" }, 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 5910e7009..514f189f7 100644 --- a/packages/coding-agent/src/modes/interactive/components/oauth-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/oauth-selector.ts @@ -17,6 +17,10 @@ export type AuthSelectorProvider = { authType: "oauth" | "api_key"; }; +export function formatAuthSelectorProviderType(authType: AuthSelectorProvider["authType"]): string { + return authType === "oauth" ? "subscription" : "API key"; +} + /** * Component that renders an auth provider selector */ @@ -40,16 +44,18 @@ export class OAuthSelectorComponent extends Container implements Focusable { private mode: "login" | "logout"; private authStorage: AuthStorage; private getAuthStatus: (providerId: string) => AuthStatus; - private onSelectCallback: (providerId: string) => void; + private onSelectCallback: (providerId: string, authType: AuthSelectorProvider["authType"]) => void; private onCancelCallback: () => void; + private showAuthTypeLabels: boolean; constructor( mode: "login" | "logout", authStorage: AuthStorage, providers: AuthSelectorProvider[], - onSelect: (providerId: string) => void, + onSelect: (providerId: string, authType: AuthSelectorProvider["authType"]) => void, onCancel: () => void, getAuthStatus?: (providerId: string) => AuthStatus, + initialSearchInput?: string, ) { super(); @@ -58,6 +64,7 @@ export class OAuthSelectorComponent extends Container implements Focusable { this.getAuthStatus = getAuthStatus ?? ((providerId) => this.authStorage.getAuthStatus(providerId)); this.allProviders = providers; this.filteredProviders = providers; + this.showAuthTypeLabels = new Set(providers.map((provider) => provider.authType)).size > 1; this.onSelectCallback = onSelect; this.onCancelCallback = onCancel; @@ -71,10 +78,13 @@ export class OAuthSelectorComponent extends Container implements Focusable { this.addChild(new Spacer(1)); this.searchInput = new Input(); + if (initialSearchInput) { + this.searchInput.setValue(initialSearchInput); + } this.searchInput.onSubmit = () => { const selectedProvider = this.filteredProviders[this.selectedIndex]; if (selectedProvider) { - this.onSelectCallback(selectedProvider.id); + this.onSelectCallback(selectedProvider.id, selectedProvider.authType); } }; this.addChild(this.searchInput); @@ -90,7 +100,7 @@ export class OAuthSelectorComponent extends Container implements Focusable { this.addChild(new DynamicBorder()); // Initial render - this.filterProviders(""); + this.filterProviders(initialSearchInput ?? ""); } private filterProviders(query: string): void { @@ -118,14 +128,17 @@ export class OAuthSelectorComponent extends Container implements Focusable { const isSelected = i === this.selectedIndex; const statusIndicator = this.formatStatusIndicator(provider); + const authTypeLabel = this.showAuthTypeLabels + ? theme.fg("muted", ` [${formatAuthSelectorProviderType(provider.authType)}]`) + : ""; let line = ""; if (isSelected) { const prefix = theme.fg("accent", "→ "); const text = theme.fg("accent", provider.name); - line = prefix + text + statusIndicator; + line = prefix + text + authTypeLabel + statusIndicator; } else { const text = ` ${theme.fg("text", provider.name)}`; - line = text + statusIndicator; + line = text + authTypeLabel + statusIndicator; } this.listContainer.addChild(new TruncatedText(line, 1, 0)); @@ -192,7 +205,7 @@ export class OAuthSelectorComponent extends Container implements Focusable { else if (kb.matches(keyData, "tui.select.confirm")) { const selectedProvider = this.filteredProviders[this.selectedIndex]; if (selectedProvider) { - this.onSelectCallback(selectedProvider.id); + this.onSelectCallback(selectedProvider.id, selectedProvider.authType); } } // Escape or Ctrl+C diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index c3c8892cf..996d6a999 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -115,7 +115,11 @@ import { FooterComponent } from "./components/footer.ts"; import { formatKeyText, keyDisplayText, keyHint, keyText, rawKeyHint } from "./components/keybinding-hints.ts"; import { LoginDialogComponent } from "./components/login-dialog.ts"; import { ModelSelectorComponent } from "./components/model-selector.ts"; -import { type AuthSelectorProvider, OAuthSelectorComponent } from "./components/oauth-selector.ts"; +import { + type AuthSelectorProvider, + formatAuthSelectorProviderType, + OAuthSelectorComponent, +} from "./components/oauth-selector.ts"; import { ScopedModelsSelectorComponent } from "./components/scoped-models-selector.ts"; import { SessionSelectorComponent } from "./components/session-selector.ts"; import { SettingsSelectorComponent } from "./components/settings-selector.ts"; @@ -255,6 +259,59 @@ export function isApiKeyLoginProvider( return !oauthProviderIds.has(providerId); } +type LoginProviderCompletionOption = { + id: string; + name: string; + authTypes: AuthSelectorProvider["authType"][]; +}; + +const AUTH_TYPE_ORDER = { oauth: 0, api_key: 1 } satisfies Record; + +function createFuzzyAutocompleteItems( + items: T[], + prefix: string, + getSearchText: (item: T) => string, + toAutocompleteItem: (item: T) => AutocompleteItem, +): AutocompleteItem[] | null { + const filtered = fuzzyFilter(items, prefix, getSearchText); + if (filtered.length === 0) return null; + return filtered.map(toAutocompleteItem); +} + +function getLoginProviderCompletionOptions( + providerOptions: readonly AuthSelectorProvider[], +): LoginProviderCompletionOption[] { + const byId = new Map(); + for (const provider of providerOptions) { + const existing = byId.get(provider.id); + if (existing) { + if (!existing.authTypes.includes(provider.authType)) { + existing.authTypes.push(provider.authType); + existing.authTypes.sort((a, b) => AUTH_TYPE_ORDER[a] - AUTH_TYPE_ORDER[b]); + } + continue; + } + byId.set(provider.id, { + id: provider.id, + name: provider.name, + authTypes: [provider.authType], + }); + } + return Array.from(byId.values()).sort((a, b) => a.name.localeCompare(b.name)); +} + +function getLoginProviderSearchText(provider: LoginProviderCompletionOption): string { + const authTypes = provider.authTypes + .map((authType) => `${authType} ${formatAuthSelectorProviderType(authType)}`) + .join(" "); + return `${provider.id} ${provider.name} ${authTypes}`; +} + +function formatLoginProviderCompletionDescription(provider: LoginProviderCompletionOption): string { + const authTypes = provider.authTypes.map(formatAuthSelectorProviderType).join("/"); + return provider.name === provider.id ? authTypes : `${provider.name} · ${authTypes}`; +} + /** * Options for InteractiveMode initialization. */ @@ -502,6 +559,7 @@ export class InteractiveMode { const slashCommands: SlashCommand[] = BUILTIN_SLASH_COMMANDS.map((command) => ({ name: command.name, description: command.description, + ...(command.argumentHint && { argumentHint: command.argumentHint }), })); const modelCommand = slashCommands.find((command) => command.name === "model"); @@ -523,12 +581,7 @@ export class InteractiveMode { label: `${m.provider}/${m.id}`, })); - // Fuzzy filter by model ID + provider in either order. - const filtered = fuzzyFilter(items, prefix, getModelSearchText); - - if (filtered.length === 0) return null; - - return filtered.map((item) => ({ + return createFuzzyAutocompleteItems(items, prefix, getModelSearchText, (item) => ({ value: item.label, label: item.id, description: item.provider, @@ -536,6 +589,18 @@ export class InteractiveMode { }; } + const loginCommand = slashCommands.find((command) => command.name === "login"); + if (loginCommand) { + loginCommand.getArgumentCompletions = (prefix: string): AutocompleteItem[] | null => { + const providers = getLoginProviderCompletionOptions(this.getLoginProviderOptions()); + return createFuzzyAutocompleteItems(providers, prefix, getLoginProviderSearchText, (provider) => ({ + value: provider.id, + label: provider.id, + description: formatLoginProviderCompletionDescription(provider), + })); + }; + } + // Convert prompt templates to SlashCommand format for autocomplete const templateCommands: SlashCommand[] = this.session.promptTemplates.map((cmd) => ({ name: cmd.name, @@ -2638,9 +2703,10 @@ export class InteractiveMode { this.editor.setText(""); return; } - if (text === "/login") { - this.showOAuthSelector("login"); + if (text === "/login" || text.startsWith("/login ")) { + const providerRef = text.startsWith("/login ") ? text.slice(7).trim() : undefined; this.editor.setText(""); + await this.handleLoginCommand(providerRef); return; } if (text === "/logout") { @@ -4717,16 +4783,96 @@ export class InteractiveMode { return options.sort((a, b) => a.name.localeCompare(b.name)); } - private showLoginAuthTypeSelector(): void { + private findLoginProviderOptions(providerRef: string): AuthSelectorProvider[] { + const normalizedProviderRef = providerRef.trim().toLowerCase(); + if (!normalizedProviderRef) { + return []; + } + + return this.getLoginProviderOptions().filter( + (provider) => + provider.id.toLowerCase() === normalizedProviderRef || + provider.name.toLowerCase() === normalizedProviderRef, + ); + } + + private async handleLoginCommand(providerRef?: string): Promise { + if (!providerRef) { + this.showLoginAuthTypeSelector(); + return; + } + + const providerOptions = this.findLoginProviderOptions(providerRef); + if (providerOptions.length === 1) { + await this.startProviderLogin(providerOptions[0]!); + return; + } + + if (providerOptions.length > 1) { + const providerIds = new Set(providerOptions.map((provider) => provider.id)); + if (providerIds.size === 1) { + this.showLoginAuthTypeSelector(providerOptions); + return; + } + } + + this.showLoginProviderSelector(undefined, providerRef); + } + + private async startProviderLogin(providerOption: AuthSelectorProvider): Promise { + if (providerOption.authType === "oauth") { + await this.showLoginDialog(providerOption.id, providerOption.name); + } else if (providerOption.id === BEDROCK_PROVIDER_ID) { + this.showBedrockSetupDialog(providerOption.id, providerOption.name); + } else { + await this.showApiKeyLoginDialog(providerOption.id, providerOption.name); + } + } + + private showLoginAuthTypeSelector(providerOptions?: AuthSelectorProvider[]): void { const subscriptionLabel = "Use a subscription"; const apiKeyLabel = "Use an API key"; + const availableAuthTypes = providerOptions + ? new Set(providerOptions.map((provider) => provider.authType)) + : new Set(["oauth", "api_key"]); + const options: string[] = []; + if (availableAuthTypes.has("oauth")) { + options.push(subscriptionLabel); + } + if (availableAuthTypes.has("api_key")) { + options.push(apiKeyLabel); + } + + if (options.length === 0) { + this.showStatus("No login methods available."); + return; + } + + if (providerOptions && options.length === 1) { + const providerOption = providerOptions[0]; + if (providerOption) { + void this.startProviderLogin(providerOption); + } + return; + } + + const title = providerOptions?.[0] + ? `Select authentication method for ${providerOptions[0].name}:` + : "Select authentication method:"; this.showSelector((done) => { const selector = new ExtensionSelectorComponent( - "Select authentication method:", - [subscriptionLabel, apiKeyLabel], + title, + options, (option) => { done(); const authType = option === subscriptionLabel ? "oauth" : "api_key"; + if (providerOptions) { + const providerOption = providerOptions.find((provider) => provider.authType === authType); + if (providerOption) { + void this.startProviderLogin(providerOption); + } + return; + } this.showLoginProviderSelector(authType); }, () => { @@ -4738,12 +4884,16 @@ export class InteractiveMode { }); } - private showLoginProviderSelector(authType: "oauth" | "api_key"): void { + private showLoginProviderSelector(authType?: AuthSelectorProvider["authType"], initialSearchInput?: string): void { const providerOptions = this.getLoginProviderOptions(authType); if (providerOptions.length === 0) { - this.showStatus( - authType === "oauth" ? "No subscription providers available." : "No API key providers available.", - ); + const message = + authType === "oauth" + ? "No subscription providers available." + : authType === "api_key" + ? "No API key providers available." + : "No login providers available."; + this.showStatus(message); return; } @@ -4752,27 +4902,28 @@ export class InteractiveMode { "login", this.session.modelRegistry.authStorage, providerOptions, - async (providerId: string) => { + async (providerId, selectedAuthType) => { done(); - const providerOption = providerOptions.find((provider) => provider.id === providerId); + const providerOption = providerOptions.find( + (provider) => provider.id === providerId && provider.authType === selectedAuthType, + ); if (!providerOption) { return; } - if (providerOption.authType === "oauth") { - await this.showLoginDialog(providerOption.id, providerOption.name); - } else if (providerOption.id === BEDROCK_PROVIDER_ID) { - this.showBedrockSetupDialog(providerOption.id, providerOption.name); - } else { - await this.showApiKeyLoginDialog(providerOption.id, providerOption.name); - } + await this.startProviderLogin(providerOption); }, () => { done(); - this.showLoginAuthTypeSelector(); + if (authType) { + this.showLoginAuthTypeSelector(); + } else { + this.ui.requestRender(); + } }, (providerId) => this.session.modelRegistry.getProviderAuthStatus(providerId), + initialSearchInput, ); return { component: selector, focus: selector }; }); diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index 7d75d3836..c705d7392 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -6,6 +6,7 @@ import { type Component, Container, type Focusable, TUI } from "../../tui/src/tu import { VirtualTerminal } from "../../tui/test/virtual-terminal.ts"; import type { AutocompleteProviderFactory } from "../src/core/extensions/types.ts"; import type { SourceInfo } from "../src/core/source-info.ts"; +import type { AuthSelectorProvider } from "../src/modes/interactive/components/oauth-selector.ts"; import { InteractiveMode } from "../src/modes/interactive/interactive-mode.ts"; import { initTheme } from "../src/modes/interactive/theme/theme.ts"; @@ -423,8 +424,62 @@ describe("InteractiveMode.createBaseAutocompleteProvider", () => { "github-copilot/gpt-5.2-codex", ]); }); -}); + test("matches login command arguments by provider id and name", async () => { + type FakeInteractiveMode = { + session: { + scopedModels: []; + modelRegistry: { getAvailable: () => [] }; + promptTemplates: []; + extensionRunner: { getRegisteredCommands: () => [] }; + resourceLoader: { getSkills: () => { skills: [] } }; + }; + settingsManager: { getEnableSkillCommands: () => boolean }; + skillCommands: Map; + sessionManager: { getCwd: () => string }; + fdPath: null; + getLoginProviderOptions: () => AuthSelectorProvider[]; + }; + + const createBaseAutocompleteProvider = ( + InteractiveMode as unknown as { + prototype: { createBaseAutocompleteProvider(this: FakeInteractiveMode): AutocompleteProvider }; + } + ).prototype.createBaseAutocompleteProvider; + const fakeThis: FakeInteractiveMode = { + session: { + scopedModels: [], + modelRegistry: { getAvailable: () => [] }, + promptTemplates: [], + extensionRunner: { getRegisteredCommands: () => [] }, + resourceLoader: { getSkills: () => ({ skills: [] }) }, + }, + settingsManager: { getEnableSkillCommands: () => false }, + skillCommands: new Map(), + sessionManager: { getCwd: () => "/tmp" }, + fdPath: null, + getLoginProviderOptions: () => [ + { id: "anthropic", name: "Anthropic", authType: "oauth" }, + { id: "anthropic", name: "Anthropic", authType: "api_key" }, + { id: "openai", name: "OpenAI", authType: "api_key" }, + ], + }; + + const provider = createBaseAutocompleteProvider.call(fakeThis); + const line = "/login subscription anthrop"; + const suggestions = await provider.getSuggestions([line], 0, line.length, { + signal: new AbortController().signal, + }); + + expect(suggestions?.items).toEqual([ + { + value: "anthropic", + label: "anthropic", + description: "Anthropic · subscription/API key", + }, + ]); + }); +}); describe("InteractiveMode.showLoadedResources", () => { beforeAll(() => { initTheme("dark");