UNPKG

@earendil-works/pi-coding-agent

Version:

Coding agent CLI with read, bash, edit, write tools and session management

202 lines • 9.12 kB
import { Container, fuzzyFilter, getKeybindings, getSelectPageIndex, Input, Spacer, TruncatedText, } from "@earendil-works/pi-tui"; import { isPreferredProvider } from "../../../core/preferred-providers.js"; import { theme } from "../theme/theme.js"; import { DynamicBorder } from "./dynamic-border.js"; import { getProviderBrandStyle, startBrandAnimation } from "./provider-brand.js"; export function formatAuthSelectorProviderType(authType, subscription) { if (authType === "api_key") return "API key"; return subscription === false ? "account" : "subscription"; } /** Themed suffix describing whether and how a login option is configured, for example " ✓ configured". */ export function formatAuthSelectorProviderStatus(provider) { if (!provider.status) return theme.fg("muted", " • not configured"); if (provider.status.type !== provider.authType) { const label = `${formatAuthSelectorProviderType(provider.status.type, provider.subscription)} configured`; return theme.fg("muted", " • ") + theme.fg("warning", label); } if (!provider.status.source || provider.status.source === "OAuth" || provider.status.source === "stored credential") { return theme.fg("success", " ✓ configured"); } const source = /^[A-Z][A-Z0-9_]*(?:, [A-Z][A-Z0-9_]*)*$/.test(provider.status.source) ? `env: ${provider.status.source}` : provider.status.source; return theme.fg("success", ` ✓ ${source}`); } const MAX_VISIBLE = 8; /** * Component that renders an auth provider selector. With `tui`, the selected row of a preferred partner provider * shows its animated brand style. */ export class OAuthSelectorComponent extends Container { capturesViewportInput = true; searchInput; // Focusable implementation - propagate to search input for IME cursor positioning _focused = false; get focused() { return this._focused; } set focused(value) { this._focused = value; this.searchInput.focused = value; } listContainer; allProviders; filteredProviders; selectedIndex = 0; mode; onSelectCallback; onCancelCallback; showAuthTypeLabels; animationStart = performance.now(); stopAnimation; constructor(mode, providers, onSelect, onCancel, initialSearchInput, tui) { super(); this.mode = mode; this.allProviders = providers; this.filteredProviders = providers; this.showAuthTypeLabels = new Set(providers.map((provider) => provider.authType)).size > 1; 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(); if (initialSearchInput) { this.searchInput.setValue(initialSearchInput); } this.searchInput.onSubmit = () => { const selectedProvider = this.filteredProviders[this.selectedIndex]; if (selectedProvider) { this.onSelectCallback(selectedProvider.id, selectedProvider.authType); } }; 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(initialSearchInput ?? ""); if (tui) this.stopAnimation = startBrandAnimation(tui, () => this.getSelectedBrandStyle() !== undefined); } render(width) { // The list is built from styled text, so an animated row needs a rebuild for each frame. if (this.stopAnimation && this.getSelectedBrandStyle()) this.updateList(); return super.render(width); } /** Stops the brand animation. */ dispose() { this.stopAnimation?.(); this.stopAnimation = undefined; } getSelectedBrandStyle() { const selectedProvider = this.filteredProviders[this.selectedIndex]; return selectedProvider && getProviderBrandStyle(selectedProvider.id); } filterProviders(query) { this.filteredProviders = query ? fuzzyFilter(this.allProviders, query, (provider) => `${provider.name} ${provider.id} ${provider.authType} ${provider.method?.name ?? ""}`) : this.allProviders; this.selectedIndex = Math.max(0, Math.min(this.selectedIndex, Math.max(0, this.filteredProviders.length - 1))); this.updateList(); } updateList() { this.listContainer.clear(); const startIndex = Math.max(0, Math.min(this.selectedIndex - Math.floor(MAX_VISIBLE / 2), this.filteredProviders.length - MAX_VISIBLE)); const endIndex = Math.min(startIndex + MAX_VISIBLE, 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 = formatAuthSelectorProviderStatus(provider); // U+2726 is single-width in every terminal, unlike star emoji or ambiguous-width symbols such as U+2605. const partnerMarker = isPreferredProvider(provider.id) ? theme.fg("muted", " ✦") : ""; const authTypeLabel = this.showAuthTypeLabels ? theme.fg("muted", ` [${formatAuthSelectorProviderType(provider.authType, provider.subscription)}]`) : ""; let line = ""; if (isSelected) { const prefix = theme.fg("accent", "→ "); const brandStyle = this.stopAnimation && getProviderBrandStyle(provider.id); const text = brandStyle ? brandStyle(provider.name, performance.now() - this.animationStart) : theme.fg("accent", provider.name); line = prefix + text + partnerMarker + authTypeLabel + statusIndicator; } else { const text = ` ${theme.fg("text", provider.name)}`; line = text + partnerMarker + authTypeLabel + 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)); } } handleInput(keyData) { const kb = getKeybindings(); const pageIndex = getSelectPageIndex(kb, keyData, this.selectedIndex, this.filteredProviders.length, MAX_VISIBLE); // 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(); } // Page up/down else if (pageIndex !== undefined) { if (this.filteredProviders.length === 0) return; this.selectedIndex = pageIndex; this.updateList(); } // Enter else if (kb.matches(keyData, "tui.select.confirm")) { const selectedProvider = this.filteredProviders[this.selectedIndex]; if (selectedProvider) { this.onSelectCallback(selectedProvider.id, selectedProvider.authType); } } // 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()); } } } //# sourceMappingURL=oauth-selector.js.map