Files
sproutclaw/packages/coding-agent/src/modes/interactive/components/oauth-selector.ts

184 lines
5.3 KiB
TypeScript

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 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.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);
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;
// Check if user is configured for this provider
const credentials = this.authStorage.get(provider.id);
const statusIndicator = credentials
? theme.fg("success", " ✓ configured")
: theme.fg("muted", " • unconfigured");
let line = "";
if (isSelected) {
const prefix = theme.fg("accent", "→ ");
const text = theme.fg("accent", provider.name);
line = prefix + text + statusIndicator;
} else {
const text = ` ${provider.name}`;
line = text + statusIndicator;
}
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.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}`), 0, 0));
}
}
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());
}
}
}