mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(coding-agent): animate Sign in with Radius and add Radius intro (#10295)
This commit is contained in:
parent
04c8ea9dbe
commit
40d711cd5b
4 changed files with 290 additions and 46 deletions
|
|
@ -0,0 +1,116 @@
|
|||
import { Container, SelectList, Spacer, Text, type TUI } from "@earendil-works/pi-tui";
|
||||
import { getSelectListTheme, theme } from "../theme/theme.ts";
|
||||
import { DynamicBorder } from "./dynamic-border.ts";
|
||||
import { keyHint, rawKeyHint } from "./keybinding-hints.ts";
|
||||
import { radiusShimmer } from "./radius-shimmer.ts";
|
||||
|
||||
const ANIMATION_FRAME_MS = 50;
|
||||
|
||||
type RadiusLoginSelectorOptions = {
|
||||
intro?: string;
|
||||
shimmer?: {
|
||||
option: string;
|
||||
text: string;
|
||||
suffix: string;
|
||||
};
|
||||
};
|
||||
|
||||
/** Selector for the Radius login flow, including its intro and animated sign-in option. */
|
||||
export class RadiusLoginSelectorComponent extends Container {
|
||||
private readonly tui: TUI;
|
||||
private readonly shimmer: RadiusLoginSelectorOptions["shimmer"];
|
||||
private readonly selectList: SelectList;
|
||||
private selectedOption: string | undefined;
|
||||
private animationTimer: ReturnType<typeof setInterval> | undefined;
|
||||
private animationStart = 0;
|
||||
|
||||
constructor(
|
||||
tui: TUI,
|
||||
title: string,
|
||||
options: string[],
|
||||
onSelect: (option: string) => void,
|
||||
onCancel: () => void,
|
||||
selectorOptions: RadiusLoginSelectorOptions = {},
|
||||
) {
|
||||
super();
|
||||
this.tui = tui;
|
||||
this.shimmer = selectorOptions.shimmer;
|
||||
this.selectedOption = options[0];
|
||||
|
||||
const listTheme = getSelectListTheme();
|
||||
const items = options.map((option) => ({ value: option, label: option }));
|
||||
this.selectList = new SelectList(items, Math.max(1, items.length), {
|
||||
...listTheme,
|
||||
selectedText: (line) =>
|
||||
this.selectedOption === this.shimmer?.option
|
||||
? this.animatedLine(performance.now() - this.animationStart)
|
||||
: listTheme.selectedText(line),
|
||||
});
|
||||
this.selectList.onSelectionChange = (item) => this.selectOption(item.value);
|
||||
this.selectList.onSelect = (item) => {
|
||||
this.stopAnimation();
|
||||
onSelect(item.value);
|
||||
};
|
||||
this.selectList.onCancel = () => {
|
||||
this.stopAnimation();
|
||||
onCancel();
|
||||
};
|
||||
|
||||
this.addChild(new DynamicBorder());
|
||||
this.addChild(new Spacer(1));
|
||||
if (selectorOptions.intro) {
|
||||
this.addChild(new Text(theme.fg("text", selectorOptions.intro), 1, 0));
|
||||
this.addChild(new Spacer(1));
|
||||
}
|
||||
this.addChild(new Text(theme.fg("accent", theme.bold(title)), 1, 0));
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(this.selectList);
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(
|
||||
new Text(
|
||||
rawKeyHint("↑↓", "navigate") +
|
||||
" " +
|
||||
keyHint("tui.select.confirm", "select") +
|
||||
" " +
|
||||
keyHint("tui.select.cancel", "cancel"),
|
||||
1,
|
||||
0,
|
||||
),
|
||||
);
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(new DynamicBorder());
|
||||
if (this.selectedOption === this.shimmer?.option) this.startAnimation();
|
||||
}
|
||||
|
||||
private selectOption(option: string): void {
|
||||
this.selectedOption = option;
|
||||
if (option === this.shimmer?.option) this.startAnimation();
|
||||
else this.stopAnimation();
|
||||
}
|
||||
|
||||
private animatedLine(elapsedMs: number): string {
|
||||
if (!this.shimmer) return "";
|
||||
return theme.fg("accent", "→ ") + radiusShimmer(this.shimmer.text, elapsedMs) + this.shimmer.suffix;
|
||||
}
|
||||
|
||||
private startAnimation(): void {
|
||||
if (this.animationTimer) return;
|
||||
this.animationStart = performance.now();
|
||||
this.animationTimer = setInterval(() => this.tui.requestRender(), ANIMATION_FRAME_MS);
|
||||
this.animationTimer.unref?.();
|
||||
}
|
||||
|
||||
private stopAnimation(): void {
|
||||
if (!this.animationTimer) return;
|
||||
clearInterval(this.animationTimer);
|
||||
this.animationTimer = undefined;
|
||||
}
|
||||
|
||||
handleInput(keyData: string): void {
|
||||
this.selectList.handleInput(keyData);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.stopAnimation();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import { type Color, foregroundAnsi, mixColors, parseColor } from "@earendil-works/pi-tui";
|
||||
import { theme } from "../theme/theme.ts";
|
||||
|
||||
/** The four colors of the Radius logo, in the order they stream across the text. */
|
||||
const RADIUS_COLORS: readonly Color[] = ["#4d9abf", "#83ccd2", "#f1be57", "#f09082"].map((hex) => parseColor(hex));
|
||||
/** Width of each color band, in characters. */
|
||||
const CHARS_PER_COLOR = 4;
|
||||
const CHARS_PER_SECOND = 10;
|
||||
|
||||
/**
|
||||
* Color `text` with the Radius logo colors flowing left to right. `elapsedMs` is the animation time;
|
||||
* the pattern repeats every `RADIUS_COLORS.length * CHARS_PER_COLOR` characters.
|
||||
*/
|
||||
export function radiusShimmer(text: string, elapsedMs: number): string {
|
||||
const mode = theme.getColorMode();
|
||||
const cycle = RADIUS_COLORS.length * CHARS_PER_COLOR;
|
||||
const offset = (elapsedMs / 1000) * CHARS_PER_SECOND;
|
||||
let result = "";
|
||||
let index = 0;
|
||||
for (const char of text) {
|
||||
const position = (((index - offset) % cycle) + cycle) % cycle;
|
||||
const band = Math.floor(position / CHARS_PER_COLOR);
|
||||
const t = position / CHARS_PER_COLOR - band;
|
||||
// Smoothstep keeps each band recognizable while still blending into the next one.
|
||||
const amount = t * t * (3 - 2 * t);
|
||||
const from = RADIUS_COLORS[band] as Color;
|
||||
const to = RADIUS_COLORS[(band + 1) % RADIUS_COLORS.length] as Color;
|
||||
result += foregroundAnsi(mixColors(from, to, amount, "srgb"), mode) + char;
|
||||
index++;
|
||||
}
|
||||
return `${result}\x1b[39m`;
|
||||
}
|
||||
|
|
@ -160,6 +160,7 @@ import {
|
|||
} from "./components/oauth-selector.ts";
|
||||
import { piLogoLines } from "./components/pi-logo.ts";
|
||||
import { playPiLogoAnimation } from "./components/pi-logo-animation.lazy.ts";
|
||||
import { RadiusLoginSelectorComponent } from "./components/radius-login-selector.ts";
|
||||
import { ScopedModelsSelectorComponent } from "./components/scoped-models-selector.ts";
|
||||
import { SessionSelectorComponent } from "./components/session-selector.ts";
|
||||
import { SettingsSelectorComponent } from "./components/settings-selector.ts";
|
||||
|
|
@ -363,6 +364,8 @@ type LoginProviderCompletionOption = {
|
|||
subscription?: boolean;
|
||||
};
|
||||
|
||||
const RADIUS_LOGIN_INTRO = "Radius is a service crafted for Pi by the builders of Pi, Earendil Works";
|
||||
|
||||
const AUTH_TYPE_ORDER = { oauth: 0, api_key: 1 } satisfies Record<AuthSelectorProvider["authType"], number>;
|
||||
|
||||
function createFuzzyAutocompleteItems<T>(
|
||||
|
|
@ -5791,9 +5794,9 @@ export class InteractiveMode {
|
|||
const radiusOption = providerOptions
|
||||
? undefined
|
||||
: this.getLoginProviderOptions("oauth").find((provider) => provider.id === RADIUS_PROVIDER_ID);
|
||||
const radiusLabel = radiusOption
|
||||
? `Sign in with ${radiusOption.name}${formatAuthSelectorProviderStatus(radiusOption)}`
|
||||
: undefined;
|
||||
const radiusText = radiusOption ? `Sign in with ${radiusOption.name}` : undefined;
|
||||
const radiusStatus = radiusOption ? formatAuthSelectorProviderStatus(radiusOption) : "";
|
||||
const radiusLabel = radiusText ? `${radiusText}${radiusStatus}` : undefined;
|
||||
const oauthProvider = providerOptions?.find((provider) => provider.authType === "oauth");
|
||||
const oauthLoginLabel =
|
||||
oauthProvider?.method && "loginLabel" in oauthProvider.method ? oauthProvider.method.loginLabel : undefined;
|
||||
|
|
@ -5828,33 +5831,33 @@ export class InteractiveMode {
|
|||
? `Select authentication method for ${providerOptions[0].name}:`
|
||||
: "Select authentication method:";
|
||||
this.showSelector((done) => {
|
||||
const selector = new ExtensionSelectorComponent(
|
||||
title,
|
||||
options,
|
||||
(option) => {
|
||||
done();
|
||||
if (radiusOption && option === radiusLabel) {
|
||||
void this.startProviderLogin(radiusOption, () => this.showLoginAuthTypeSelector());
|
||||
return;
|
||||
const onSelect = (option: string) => {
|
||||
done();
|
||||
if (radiusOption && option === radiusLabel) {
|
||||
void this.startProviderLogin(radiusOption, () => this.showLoginAuthTypeSelector());
|
||||
return;
|
||||
}
|
||||
const authType = option === subscriptionLabel ? "oauth" : "api_key";
|
||||
if (providerOptions) {
|
||||
const providerOption = providerOptions.find((provider) => provider.authType === authType);
|
||||
if (providerOption) {
|
||||
void this.startProviderLogin(providerOption, () => this.showLoginAuthTypeSelector(providerOptions));
|
||||
}
|
||||
const authType = option === subscriptionLabel ? "oauth" : "api_key";
|
||||
if (providerOptions) {
|
||||
const providerOption = providerOptions.find((provider) => provider.authType === authType);
|
||||
if (providerOption) {
|
||||
void this.startProviderLogin(providerOption, () =>
|
||||
this.showLoginAuthTypeSelector(providerOptions),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.showLoginProviderSelector(authType);
|
||||
},
|
||||
() => {
|
||||
done();
|
||||
this.ui.requestRender();
|
||||
},
|
||||
);
|
||||
return { component: selector, focus: selector };
|
||||
return;
|
||||
}
|
||||
this.showLoginProviderSelector(authType);
|
||||
};
|
||||
const onCancel = () => {
|
||||
done();
|
||||
this.ui.requestRender();
|
||||
};
|
||||
const selector =
|
||||
radiusLabel && radiusText
|
||||
? new RadiusLoginSelectorComponent(this.ui, title, options, onSelect, onCancel, {
|
||||
shimmer: { option: radiusLabel, text: radiusText, suffix: radiusStatus },
|
||||
})
|
||||
: new ExtensionSelectorComponent(title, options, onSelect, onCancel);
|
||||
return { component: selector, focus: selector, dispose: () => selector.dispose() };
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -6144,6 +6147,7 @@ export class InteractiveMode {
|
|||
private showAuthSelect(
|
||||
dialog: LoginDialogComponent,
|
||||
prompt: Extract<AuthPrompt, { type: "select" }>,
|
||||
providerId: string,
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const restoreDialog = () => {
|
||||
|
|
@ -6153,20 +6157,22 @@ export class InteractiveMode {
|
|||
this.ui.requestRender();
|
||||
};
|
||||
const labels = prompt.options.map((option) => option.label);
|
||||
const selector = new ExtensionSelectorComponent(
|
||||
prompt.message,
|
||||
labels,
|
||||
(optionLabel) => {
|
||||
restoreDialog();
|
||||
const id = prompt.options.find((option) => option.label === optionLabel)?.id;
|
||||
if (id) resolve(id);
|
||||
else reject(new Error("Login cancelled"));
|
||||
},
|
||||
() => {
|
||||
restoreDialog();
|
||||
reject(new Error("Login cancelled"));
|
||||
},
|
||||
);
|
||||
const onSelect = (optionLabel: string) => {
|
||||
restoreDialog();
|
||||
const id = prompt.options.find((option) => option.label === optionLabel)?.id;
|
||||
if (id) resolve(id);
|
||||
else reject(new Error("Login cancelled"));
|
||||
};
|
||||
const onCancel = () => {
|
||||
restoreDialog();
|
||||
reject(new Error("Login cancelled"));
|
||||
};
|
||||
const selector =
|
||||
providerId === RADIUS_PROVIDER_ID
|
||||
? new RadiusLoginSelectorComponent(this.ui, prompt.message, labels, onSelect, onCancel, {
|
||||
intro: RADIUS_LOGIN_INTRO,
|
||||
})
|
||||
: new ExtensionSelectorComponent(prompt.message, labels, onSelect, onCancel);
|
||||
this.editorContainer.clear();
|
||||
this.editorContainer.addChild(selector);
|
||||
this.ui.setFocus(selector);
|
||||
|
|
@ -6174,10 +6180,10 @@ export class InteractiveMode {
|
|||
});
|
||||
}
|
||||
|
||||
private async showAuthPrompt(dialog: LoginDialogComponent, prompt: AuthPrompt): Promise<string> {
|
||||
private async showAuthPrompt(dialog: LoginDialogComponent, prompt: AuthPrompt, providerId: string): Promise<string> {
|
||||
let response: Promise<string>;
|
||||
if (prompt.type === "select") {
|
||||
response = this.showAuthSelect(dialog, prompt);
|
||||
response = this.showAuthSelect(dialog, prompt, providerId);
|
||||
} else if (prompt.type === "manual_code") {
|
||||
response = dialog.showManualInput(prompt.message);
|
||||
} else {
|
||||
|
|
@ -6221,7 +6227,7 @@ export class InteractiveMode {
|
|||
method,
|
||||
{
|
||||
signal: dialog.signal,
|
||||
prompt: (prompt) => this.showAuthPrompt(dialog, prompt),
|
||||
prompt: (prompt) => this.showAuthPrompt(dialog, prompt, providerId),
|
||||
notify: (event) => this.notifyAuthDialog(dialog, event),
|
||||
},
|
||||
{ getDeviceId: () => this.settingsManager.getOrCreateDeviceId() },
|
||||
|
|
|
|||
90
packages/coding-agent/test/radius-login-selector.test.ts
Normal file
90
packages/coding-agent/test/radius-login-selector.test.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { afterEach, beforeAll, describe, expect, test, vi } from "vitest";
|
||||
import type { TUI } from "../../tui/src/tui.ts";
|
||||
import { RadiusLoginSelectorComponent } from "../src/modes/interactive/components/radius-login-selector.ts";
|
||||
import { radiusShimmer } from "../src/modes/interactive/components/radius-shimmer.ts";
|
||||
import { initTheme } from "../src/modes/interactive/theme/theme.ts";
|
||||
|
||||
const DOWN = "\x1b[B";
|
||||
const UP = "\x1b[A";
|
||||
|
||||
function stripAnsi(text: string): string {
|
||||
return text.replace(/\x1b\[[0-9;]*m/g, "");
|
||||
}
|
||||
|
||||
describe("RadiusLoginSelectorComponent", () => {
|
||||
beforeAll(() => initTheme("dark"));
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
test("does not schedule renders without an animated option", () => {
|
||||
vi.useFakeTimers();
|
||||
const requestRender = vi.fn();
|
||||
const tui = { requestRender } as unknown as TUI;
|
||||
const selector = new RadiusLoginSelectorComponent(
|
||||
tui,
|
||||
"Select:",
|
||||
["First"],
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
vi.advanceTimersByTime(500);
|
||||
expect(requestRender).not.toHaveBeenCalled();
|
||||
selector.dispose();
|
||||
});
|
||||
|
||||
test("animates the option only while it is selected", () => {
|
||||
vi.useFakeTimers();
|
||||
const requestRender = vi.fn();
|
||||
const tui = { requestRender } as unknown as TUI;
|
||||
const selector = new RadiusLoginSelectorComponent(
|
||||
tui,
|
||||
"Select:",
|
||||
["First", "Radius"],
|
||||
() => {},
|
||||
() => {},
|
||||
{ shimmer: { option: "Radius", text: "Radius", suffix: "" } },
|
||||
);
|
||||
|
||||
vi.advanceTimersByTime(500);
|
||||
expect(requestRender).not.toHaveBeenCalled();
|
||||
|
||||
selector.handleInput(DOWN);
|
||||
selector.render(80);
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(requestRender).toHaveBeenCalled();
|
||||
expect(stripAnsi(selector.render(80).join("\n"))).toContain("→ Radius");
|
||||
|
||||
selector.handleInput(UP);
|
||||
requestRender.mockClear();
|
||||
vi.advanceTimersByTime(500);
|
||||
expect(requestRender).not.toHaveBeenCalled();
|
||||
selector.dispose();
|
||||
});
|
||||
|
||||
test("shows an intro above the title", () => {
|
||||
const tui = { requestRender: vi.fn() } as unknown as TUI;
|
||||
const selector = new RadiusLoginSelectorComponent(
|
||||
tui,
|
||||
"Sign in to Radius:",
|
||||
["Browser"],
|
||||
() => {},
|
||||
() => {},
|
||||
{ intro: "Radius is a service" },
|
||||
);
|
||||
const lines = selector.render(80).map((line) => stripAnsi(line).trim());
|
||||
const intro = lines.indexOf("Radius is a service");
|
||||
expect(intro).toBeGreaterThan(-1);
|
||||
expect(lines[intro + 1]).toBe("");
|
||||
expect(lines[intro + 2]).toBe("Sign in to Radius:");
|
||||
});
|
||||
});
|
||||
|
||||
describe("radiusShimmer", () => {
|
||||
test("uses the Radius logo colors and moves over time", () => {
|
||||
const first = radiusShimmer("Sign in with Radius", 0);
|
||||
expect(stripAnsi(first)).toBe("Sign in with Radius");
|
||||
expect(first).toContain("38;2;77;154;191m"); // #4d9abf
|
||||
expect(radiusShimmer("Sign in with Radius", 400)).not.toBe(first);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue