feat(coding-agent): animate Sign in with Radius and add Radius intro (#10295)

This commit is contained in:
niloc 2026-10-01 14:44:39 +02:00 • committed by GitHub
parent 04c8ea9dbe
commit 40d711cd5b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 290 additions and 46 deletions

View file

@ -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();
}
}

View file

@ -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`;
}

View file

@ -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() },

View 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);
});
});