feat(coding-agent): add searchable auth provider login flow (#3572)
This commit is contained in:
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user