diff --git a/packages/coding-agent/src/modes/interactive/components/radius-login-selector.ts b/packages/coding-agent/src/modes/interactive/components/radius-login-selector.ts new file mode 100644 index 000000000..7bb403c85 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/radius-login-selector.ts @@ -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 | 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(); + } +} diff --git a/packages/coding-agent/src/modes/interactive/components/radius-shimmer.ts b/packages/coding-agent/src/modes/interactive/components/radius-shimmer.ts new file mode 100644 index 000000000..c950aae00 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/radius-shimmer.ts @@ -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`; +} diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 3e450e991..83aa9d76e 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -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; function createFuzzyAutocompleteItems( @@ -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, + providerId: string, ): Promise { 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 { + private async showAuthPrompt(dialog: LoginDialogComponent, prompt: AuthPrompt, providerId: string): Promise { let response: Promise; 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() }, diff --git a/packages/coding-agent/test/radius-login-selector.test.ts b/packages/coding-agent/test/radius-login-selector.test.ts new file mode 100644 index 000000000..aa31f1a2b --- /dev/null +++ b/packages/coding-agent/test/radius-login-selector.test.ts @@ -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); + }); +});