mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-07-09 17:28:45 +00:00
feat(coding-agent): support provider arguments for login
This commit is contained in:
parent
62f45badae
commit
312bc713bb
5 changed files with 257 additions and 36 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue