feat(coding-agent): support provider arguments for login

This commit is contained in:
Armin Ronacher 2026-07-08 12:06:00 +02:00
parent 62f45badae
commit 312bc713bb
5 changed files with 257 additions and 36 deletions

View file

@ -4,6 +4,7 @@
### Added
- Added `/login <provider>` 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.

View file

@ -13,11 +13,12 @@ export interface SlashCommandInfo {
export interface BuiltinSlashCommand {
name: string;
description: string;
argumentHint?: string;
}
export const BUILTIN_SLASH_COMMANDS: ReadonlyArray<BuiltinSlashCommand> = [
{ name: "settings", description: "Open settings menu" },
{ name: "model", description: "Select model (opens selector UI)" },
{ name: "model", description: "Select model (opens selector UI)", argumentHint: "<provider/model>" },
{ 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<BuiltinSlashCommand> = [
{ 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: "<provider>" },
{ name: "logout", description: "Remove provider authentication" },
{ name: "new", description: "Start a new session" },
{ name: "compact", description: "Manually compact the session context" },

View file

@ -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

View file

@ -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<AuthSelectorProvider["authType"], number>;
function createFuzzyAutocompleteItems<T>(
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<string, LoginProviderCompletionOption>();
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<void> {
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<void> {
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<AuthSelectorProvider["authType"]>(["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 };
});

View file

@ -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<string, string>;
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");