Something went wrong. Try again.
fork of prime agent with changes i want but are not upstreamable probqbly (and some fixes)
Something went wrong. Try again.
13 kB · 419 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420import { win32 } from "node:path";import { getOAuthProviders } from "@earendil-works/pi-ai/oauth";import { type Component, Container, type Focusable, getCapabilities, getKeybindings, Spacer, Text, type TUI,} from "@earendil-works/pi-tui";import { execFileHidden } from "../../../utils/child-process.js";import { copyToClipboard } from "../../../utils/clipboard.js";import { theme } from "../theme/theme.js";import { formatKeyText, keyHint } from "./keybinding-hints.js";import { MenuPanel, MenuSearchInput } from "./menu-panel.js";import { shouldTreatAsBack } from "./modal-back.js";import { isOnboardingExitKey } from "./onboarding-exit.js";
function isTextEntryKeybinding(key: string): boolean { const parts = key.toLowerCase().split("+"); const keyPart = parts.at(-1); return !parts.includes("ctrl") && !parts.includes("alt") && (keyPart === "space" || keyPart?.length === 1);}
function isPrintableInput(data: string): boolean { return data.length === 1 && data >= " " && data !== "\x7f";}
/** * Login dialog component - replaces the prompt area during provider login flows */export class LoginDialogComponent extends Container implements Focusable { private contentContainer: Container; private input: MenuSearchInput; private tui: TUI; private abortController = new AbortController(); private inputResolver?: (value: string) => void; private inputRejecter?: (error: Error) => void; // True only while the editable paste field is actually shown in the panel. // Tracks visibility directly rather than inferring it from inputResolver, // which can outlive the field when a new screen clears the content. private inputVisible = false; private continueResolver?: () => void; private continueRejecter?: (error: Error) => void; private authUrl?: string; private authActions?: Text; private inputSpacer?: Spacer;
// Focusable implementation - propagate to input for IME cursor positioning private _focused = false; get focused(): boolean { return this._focused; } set focused(value: boolean) { this._focused = value; this.input.focused = value; }
constructor( tui: TUI, providerId: string, private onComplete: (success: boolean, message?: string) => void, providerNameOverride?: string, titleOverride?: string, private dialogOptions: { topRule?: boolean; hideTitle?: boolean; onExit?: () => void } = {}, ) { super(); this.tui = tui;
const providerInfo = getOAuthProviders().find((p) => p.id === providerId); const providerName = providerNameOverride || providerInfo?.name || providerId; const title = titleOverride ?? `Login to ${providerName}`;
// The top rule keeps the inline login section separate from the transcript. // Surfaces that own the screen above the panel (onboarding) turn both the // rule and the title off: they already say where the user is. const panel = new MenuPanel({ title: this.dialogOptions.hideTitle ? "" : title, inline: true, topRule: this.dialogOptions.topRule ?? true, }); this.addChild(panel);
// Dynamic content area this.contentContainer = new Container(); panel.addChild(this.contentContainer);
// Input (always present, used when needed) // Plain field: the enclosing rules read as clutter in the login panel. this.input = new MenuSearchInput("Paste value", true, true); this.input.onSubmit = () => { if (this.inputResolver) { this.inputResolver(this.input.getValue()); this.inputResolver = undefined; this.inputRejecter = undefined; } }; }
get signal(): AbortSignal { return this.abortController.signal; }
/** Cancel from outside the panel, e.g. when a session reset unmounts it. */ abort(): void { this.cancel(); }
private cancel(): void { this.abortController.abort(); if (this.inputRejecter) { this.inputRejecter(new Error("Login cancelled")); this.inputResolver = undefined; this.inputRejecter = undefined; } if (this.continueRejecter) { this.continueRejecter(new Error("Login cancelled")); this.continueResolver = undefined; this.continueRejecter = undefined; } this.onComplete(false, "Login cancelled"); }
/** * Called by onAuth callback - show URL and optional instructions */ showAuth(url: string, instructions?: string): void { this.startContent(); this.authUrl = url; const linkedUrl = getCapabilities().hyperlinks ? `\x1b]8;;${url}\x07${url}\x1b]8;;\x07` : url; this.contentContainer.addChild(new Text(theme.fg("text", linkedUrl), 0, 0)); // Keep the browser-step text visually distinct from the URL. this.addSectionSpacer(); // Provider instructions already describe the browser step. if (instructions) { this.addInstructions(instructions); } else { this.addMutedText("Complete the sign-in in your browser."); } this.authActions = new Text(this.getAuthActionsText(), 0, 0); this.contentContainer.addChild(this.authActions);
// Try to open browser const [command, ...args] = process.platform === "darwin" ? ["open", url] : process.platform === "win32" ? [ win32.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "rundll32.exe"), "url.dll,FileProtocolHandler", url, ] : ["xdg-open", url]; execFileHidden(command, args, {}, () => {});
this.tui.requestRender(); }
/** * Show input for manual code/URL entry (for callback server providers) */ showManualInput(prompt: string): Promise<string> { this.addSectionSpacer(); this.addMutedText(prompt); this.addInputField(); this.tui.requestRender();
return this.waitForInput(); }
/** Append content while keeping the key-hint row as the panel's last row. */ private addChildAboveHints(component: Component): void { if (!this.authActions) { this.contentContainer.addChild(component); return; } this.contentContainer.removeChild(this.authActions); this.contentContainer.addChild(component); this.contentContainer.addChild(this.authActions); this.authActions.setText(this.getAuthActionsText()); }
/** Append the paste field plus the single key-hint line at the panel bottom. */ private addInputField(): void { this.contentContainer.removeChild(this.input); if (this.inputSpacer) { this.contentContainer.removeChild(this.inputSpacer); } else { // A blank row keeps the key hints off the field. It is retained so a // second prompt moves it instead of stacking another blank row. this.inputSpacer = new Spacer(1); } if (this.authActions) { this.contentContainer.removeChild(this.authActions); } else { this.authActions = new Text(this.getAuthActionsText(), 0, 0); } this.contentContainer.addChild(this.input); this.inputVisible = true; this.contentContainer.addChild(this.inputSpacer); this.contentContainer.addChild(this.authActions); this.authActions.setText(this.getAuthActionsText()); }
/** * Wait for the next submission of the already-visible input. */ waitForInput(): Promise<string> { return new Promise((resolve, reject) => { this.inputResolver = resolve; this.inputRejecter = reject; }); }
/** * Called by onPrompt callback - show prompt and wait for input * Note: Does NOT clear content, appends to existing (preserves URL from showAuth) */ showPrompt(message: string, placeholder?: string): Promise<string> { this.addSectionSpacer(); this.addSectionTitle(message); if (placeholder) { this.contentContainer.addChild(new Text(theme.fg("muted", `e.g., ${placeholder}`), 0, 0)); } this.addInputField();
this.input.setValue(""); this.tui.requestRender();
return this.waitForInput(); }
/** * Show informational text without prompting for input. */ showInfo(lines: string[]): void { this.startContent(); for (const line of lines) { this.contentContainer.addChild(new Text(line, 0, 0)); } this.contentContainer.addChild(new Spacer(1)); this.contentContainer.addChild(new Text(theme.fg("muted", keyHint("tui.select.cancel", "close")), 0, 0)); this.tui.requestRender(); }
showContinueInfo(lines: string[]): Promise<void> { this.startContent(); for (const line of lines) { this.contentContainer.addChild(new Text(line, 0, 0)); } this.contentContainer.addChild(new Spacer(1)); this.contentContainer.addChild( new Text( theme.fg( "muted", `${keyHint("tui.select.confirm", "continue")} ${keyHint("tui.select.cancel", "cancel")}`, ), 0, 0, ), ); this.tui.requestRender();
return new Promise((resolve, reject) => { this.continueResolver = resolve; this.continueRejecter = reject; }); }
/** * Show waiting message (for polling flows like GitHub Copilot) */ showWaiting(message: string): void { this.addSectionSpacer(); this.addChildAboveHints(new Text(theme.fg("accent", message), 0, 0)); if (!this.authActions) { this.authActions = new Text(this.getAuthActionsText(), 0, 0); this.contentContainer.addChild(this.authActions); } this.tui.requestRender(); }
/** * Called by onProgress callback */ showProgress(message: string): void { if (this.contentContainer.children.length === 0) { this.startContent(); this.addSectionTitle("Preparing authentication"); } this.addChildAboveHints(new Text(theme.fg("muted", message), 0, 0)); this.tui.requestRender(); }
private startContent(): void { this.contentContainer.clear(); this.authUrl = undefined; this.authActions = undefined; this.inputSpacer = undefined; // The cleared panel no longer shows the paste field. this.inputVisible = false; this.contentContainer.addChild(new Spacer(1)); }
private addSectionSpacer(): void { if (this.contentContainer.children.length === 0) { this.startContent(); return; } this.addChildAboveHints(new Spacer(1)); }
private addInstructions(instructions: string): void { const codeMatch = /^(?:Code|Enter code):\s*(.+)$/i.exec(instructions.trim()); if (codeMatch?.[1]) { // A blank row separates the sign-in link from the code below it. this.contentContainer.addChild(new Spacer(1)); this.addLabel("Verification code"); this.contentContainer.addChild(new Text(theme.bold(theme.fg("text", codeMatch[1])), 0, 0)); return; } this.contentContainer.addChild(new Text(theme.fg("text", instructions), 0, 0)); }
private addSectionTitle(text: string): void { this.contentContainer.addChild(new Text(theme.fg("text", text), 0, 0)); }
private addLabel(text: string): void { this.contentContainer.addChild(new Text(theme.fg("muted", text), 0, 0)); }
private addMutedText(text: string): void { this.contentContainer.addChild(new Text(theme.fg("muted", text), 0, 0)); }
private getAuthActionsText(status?: "copied" | "failed"): string { const configuredCopyKeys = getKeybindings().getKeys("app.clipboard.copyLoginUrl"); const copyKeys = this.inputVisible ? configuredCopyKeys.filter((key) => !isTextEntryKeybinding(key)) : configuredCopyKeys.slice(0, 1); const copyHint = copyKeys.length > 0 ? theme.fg("dim", formatKeyText(copyKeys.join("/"))) + theme.fg("muted", ` ${status === "failed" ? "retry" : "copy"}`) : undefined; const statusText = status === "copied" ? theme.fg("success", "Copied sign-in link") : status === "failed" ? theme.fg("error", "Failed to copy sign-in link") : undefined; const submitHint = this.inputVisible ? keyHint("tui.select.confirm", "submit") : undefined; return [submitHint, statusText, copyHint, keyHint("tui.select.cancel", "cancel")] .filter((part): part is string => part !== undefined) .join(" "); }
private async copyAuthUrl(): Promise<void> { const url = this.authUrl; const actions = this.authActions; if (!url || !actions) return;
try { await copyToClipboard(url); if (this.authUrl === url && this.authActions === actions) { actions.setText(this.getAuthActionsText("copied")); this.tui.requestRender(); } } catch { if (this.authUrl === url && this.authActions === actions) { actions.setText(this.getAuthActionsText("failed")); this.tui.requestRender(); } } }
handleInput(data: string): void { const kb = getKeybindings();
// On the onboarding surface the exit keys must quit the app; cancel // would drop the user into an unconfigured chat instead. if (this.dialogOptions.onExit && isOnboardingExitKey(data)) { this.dialogOptions.onExit(); return; }
if ( this.authUrl && kb.matches(data, "app.clipboard.copyLoginUrl") && (!this.inputVisible || !isPrintableInput(data)) ) { void this.copyAuthUrl(); return; }
// Left arrow acts as "back" like Esc. While the editable field is actually // shown, only treat it as back at the start of the text so left still moves // the cursor mid-edit; on info/continue screens there is no field to guard. const backGuardInput = this.inputVisible ? this.input : undefined; if (kb.matches(data, "tui.select.cancel") || shouldTreatAsBack(data, backGuardInput)) { this.cancel(); return; }
if (this.continueResolver && kb.matches(data, "tui.select.confirm")) { const resolve = this.continueResolver; this.continueResolver = undefined; this.continueRejecter = undefined; resolve(); return; }
// Pass to input this.input.handleInput(data); }}