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 { getAuthSelectorIndicator } from "./auth-selector-status.js"; import { DynamicBorder } from "./dynamic-border.js"; export type AuthSelectorProvider = { id: string; name: string; authType: "oauth" | "api_key"; }; /** * Component that renders an auth provider selector */ 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: AuthSelectorProvider[]; private filteredProviders: AuthSelectorProvider[]; private selectedIndex: number = 0; private mode: "login" | "logout"; private authStorage: AuthStorage; private onSelectCallback: (providerId: string) => void; private onCancelCallback: () => void; constructor( mode: "login" | "logout", authStorage: AuthStorage, providers: AuthSelectorProvider[], onSelect: (providerId: string) => void, onCancel: () => void, ) { super(); this.mode = mode; this.authStorage = authStorage; this.allProviders = providers; this.filteredProviders = providers; this.onSelectCallback = onSelect; this.onCancelCallback = onCancel; // Add top border this.addChild(new DynamicBorder()); this.addChild(new Spacer(1)); // Add title const title = mode === "login" ? "Select provider to configure:" : "Select provider to logout:"; this.addChild(new TruncatedText(theme.fg("accent", theme.bold(title)), 1, 0)); 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); this.addChild(new Spacer(1)); // Add bottom border this.addChild(new DynamicBorder()); // Initial render this.filterProviders(""); } 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(); 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; const statusIndicator = this.formatStatusIndicator(provider); let line = ""; if (isSelected) { const prefix = theme.fg("accent", "→ "); const text = theme.fg("accent", provider.name); line = prefix + text + statusIndicator; } else { const text = ` ${theme.fg("text", provider.name)}`; line = text + statusIndicator; } this.listContainer.addChild(new TruncatedText(line, 1, 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, 1, 0)); } // Show "no providers" if empty if (this.filteredProviders.length === 0) { const message = 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}`), 1, 0)); } } private formatStatusIndicator(provider: AuthSelectorProvider): string { const status = provider.authType === "api_key" ? this.authStorage.getAuthStatus(provider.id) : undefined; const indicator = getAuthSelectorIndicator(provider.authType, this.authStorage.get(provider.id), status); if (indicator.kind === "configured") { return theme.fg("success", ` ✓ ${indicator.label}`); } if (indicator.kind === "configured-other") { return theme.fg("muted", " • ") + theme.fg("warning", indicator.label); } const base = theme.fg("muted", " • unconfigured"); if (indicator.kind === "environment") { return base + theme.fg("success", ` · env: ${indicator.label}`); } if (indicator.kind === "runtime") { return base + theme.fg("success", " · runtime API key"); } if (indicator.kind === "fallback") { return base + theme.fg("success", " · custom API key"); } return base; } handleInput(keyData: string): void { 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")) { 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.filteredProviders[this.selectedIndex]; if (selectedProvider) { this.onSelectCallback(selectedProvider.id); } } // Escape or Ctrl+C 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()); } } }