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 index eaf7811da..c6a7bc3c6 100644 --- a/packages/coding-agent/src/modes/interactive/components/radius-login-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/radius-login-selector.ts @@ -1,24 +1,11 @@ /** - * Selectors for the Radius login flow. Internal to the interactive mode: the Radius shimmer and intro are not - * configurable and must not be exposed to other selectors. + * The `/login` menu with the animated "Sign in with Radius" option. Internal to the interactive mode: the shimmer + * is Radius-only and is not exposed to other selectors. */ -import { - type Color, - Container, - foregroundAnsi, - getKeybindings, - mixColors, - parseColor, - Spacer, - Text, - type TUI, -} from "@earendil-works/pi-tui"; +import { type Color, foregroundAnsi, mixColors, parseColor, Text, type TUI } from "@earendil-works/pi-tui"; import { theme } from "../theme/theme.ts"; -import { DynamicBorder } from "./dynamic-border.ts"; -import { keyHint, rawKeyHint } from "./keybinding-hints.ts"; - -const RADIUS_INTRO = "Radius is a service crafted for Pi by the builders of Pi, Earendil Works"; +import { ExtensionSelectorComponent } from "./extension-selector.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)); @@ -51,150 +38,77 @@ function radiusShimmer(text: string, elapsedMs: number): string { /** The "Sign in with Radius" option: `label` is the full option, starting with the animated `text`. */ type RadiusOption = { label: string; text: string }; -type SelectorCallbacks = { onSelect: (option: string) => void; onCancel: () => void }; - /** - * Top-level `/login` selector. `radiusOption` shimmers in the Radius logo colors while it is selected; the rest - * of its label (for example its status) is shown as is. + * Swaps the line `ExtensionSelectorComponent` draws for the selected Radius option with the animated one. When the + * selector's row style changes or the label wraps, the line no longer matches and the option renders normally. */ -export function createLoginMenuSelector( - tui: TUI, - title: string, - options: string[], - radiusOption: RadiusOption, - callbacks: SelectorCallbacks, -): RadiusLoginSelectorComponent { - return new RadiusLoginSelectorComponent(tui, title, options, callbacks, radiusOption, undefined); -} - -/** Radius sign-in method selector, with the Radius intro above the title. */ -export function createRadiusSignInSelector( - tui: TUI, - title: string, - options: string[], - callbacks: SelectorCallbacks, -): RadiusLoginSelectorComponent { - return new RadiusLoginSelectorComponent(tui, title, options, callbacks, undefined, RADIUS_INTRO); -} - -/** Renders like `ExtensionSelectorComponent`. Create it with the factories above. */ -class RadiusLoginSelectorComponent extends Container { - private readonly tui: TUI; - private readonly options: string[]; - private readonly callbacks: SelectorCallbacks; - private readonly radiusOption: RadiusOption | undefined; - private readonly listContainer = new Container(); - private selectedIndex = 0; +class RadiusLoginMenuComponent extends ExtensionSelectorComponent { + private readonly radiusOption: RadiusOption; + private readonly animationStart = performance.now(); private animationTimer: ReturnType | undefined; - private animationStart = 0; + private animating = false; constructor( tui: TUI, title: string, options: string[], - callbacks: SelectorCallbacks, - radiusOption: RadiusOption | undefined, - intro: string | undefined, + radiusOption: RadiusOption, + onSelect: (option: string) => void, + onCancel: () => void, ) { - super(); - this.tui = tui; - this.options = options; - this.callbacks = callbacks; - this.radiusOption = radiusOption; - - this.addChild(new DynamicBorder()); - this.addChild(new Spacer(1)); - if (intro) { - this.addChild(new Text(theme.fg("text", 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.listContainer); - this.addChild(new Spacer(1)); - this.addChild( - new Text( - rawKeyHint("↑↓", "navigate") + - " " + - keyHint("tui.select.confirm", "select") + - " " + - keyHint("tui.select.cancel", "cancel"), - 1, - 0, - ), + super( + title, + options, + (option) => { + this.stopAnimation(); + onSelect(option); + }, + () => { + this.stopAnimation(); + onCancel(); + }, ); - this.addChild(new Spacer(1)); - this.addChild(new DynamicBorder()); - this.selectionChanged(); + this.radiusOption = radiusOption; + this.animationTimer = setInterval(() => { + if (this.animating) tui.requestRender(); + }, ANIMATION_FRAME_MS); + this.animationTimer.unref?.(); } - private radiusSelected(): boolean { - return this.radiusOption !== undefined && this.options[this.selectedIndex] === this.radiusOption.label; - } - - private selectionChanged(): void { - if (this.radiusSelected()) { - if (!this.animationTimer) { - this.animationStart = performance.now(); - this.animationTimer = setInterval(() => { - this.updateList(); - this.tui.requestRender(); - }, ANIMATION_FRAME_MS); - this.animationTimer.unref?.(); - } - } else { - this.stopAnimation(); - } - this.updateList(); - } - - private updateList(): void { - this.listContainer.clear(); - for (let i = 0; i < this.options.length; i++) { - const option = this.options[i] as string; - let text: string; - if (i !== this.selectedIndex) { - text = ` ${theme.fg("text", option)}`; - } else if (this.radiusOption && option === this.radiusOption.label) { - const elapsedMs = performance.now() - this.animationStart; - const rest = option.slice(this.radiusOption.text.length); - text = theme.fg("accent", "→ ") + radiusShimmer(this.radiusOption.text, elapsedMs) + rest; - } else { - text = theme.fg("accent", "→ ") + theme.fg("accent", option); - } - this.listContainer.addChild(new Text(text, 1, 0)); + override render(width: number): string[] { + const lines = super.render(width); + const { label, text } = this.radiusOption; + const selectedLine = new Text(theme.fg("accent", "→ ") + theme.fg("accent", label), 1, 0).render(width)[0]; + const index = selectedLine === undefined ? -1 : lines.indexOf(selectedLine); + this.animating = index >= 0; + if (this.animating) { + const shimmer = radiusShimmer(text, performance.now() - this.animationStart); + const animatedLine = theme.fg("accent", "→ ") + shimmer + label.slice(text.length); + lines[index] = new Text(animatedLine, 1, 0).render(width)[0] ?? ""; } + return lines; } private stopAnimation(): void { - if (!this.animationTimer) return; clearInterval(this.animationTimer); this.animationTimer = undefined; + this.animating = false; } - handleInput(keyData: string): void { - const kb = getKeybindings(); - if (kb.matches(keyData, "tui.select.up") || keyData === "k") { - this.selectedIndex = Math.max(0, this.selectedIndex - 1); - this.selectionChanged(); - } else if (kb.matches(keyData, "tui.select.down") || keyData === "j") { - this.selectedIndex = Math.min(this.options.length - 1, this.selectedIndex + 1); - this.selectionChanged(); - } else if (kb.matches(keyData, "tui.select.confirm") || keyData === "\n") { - const selected = this.options[this.selectedIndex]; - if (selected) { - this.stopAnimation(); - this.callbacks.onSelect(selected); - } - } else if (kb.matches(keyData, "tui.select.cancel")) { - this.stopAnimation(); - this.callbacks.onCancel(); - } - } - - dispose(): void { + override dispose(): void { this.stopAnimation(); + super.dispose(); } } -export type { RadiusLoginSelectorComponent }; +/** Top-level `/login` selector whose Radius option shimmers in the Radius logo colors while it is selected. */ +export function createLoginMenuSelector( + tui: TUI, + title: string, + options: string[], + radiusOption: RadiusOption, + onSelect: (option: string) => void, + onCancel: () => void, +): ExtensionSelectorComponent { + return new RadiusLoginMenuComponent(tui, title, options, radiusOption, onSelect, onCancel); +} diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index a4d6b0c3f..cd08f6237 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -160,7 +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 { createLoginMenuSelector, createRadiusSignInSelector } from "./components/radius-login-selector.ts"; +import { createLoginMenuSelector } 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"; @@ -364,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( @@ -5869,7 +5871,8 @@ export class InteractiveMode { title, options, { label: radiusLabel, text: radiusText }, - { onSelect, onCancel }, + onSelect, + onCancel, ) : new ExtensionSelectorComponent(title, options, onSelect, onCancel); return { component: selector, focus: selector, dispose: () => selector.dispose() }; @@ -6172,20 +6175,21 @@ export class InteractiveMode { this.ui.requestRender(); }; const labels = prompt.options.map((option) => option.label); - 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 - ? createRadiusSignInSelector(this.ui, prompt.message, labels, { onSelect, onCancel }) - : new ExtensionSelectorComponent(prompt.message, labels, onSelect, onCancel); + 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")); + }, + { description: providerId === RADIUS_PROVIDER_ID ? RADIUS_LOGIN_INTRO : undefined }, + ); this.editorContainer.clear(); this.editorContainer.addChild(selector); this.ui.setFocus(selector); diff --git a/packages/coding-agent/test/radius-login-selector.test.ts b/packages/coding-agent/test/radius-login-selector.test.ts deleted file mode 100644 index c161dcc5e..000000000 --- a/packages/coding-agent/test/radius-login-selector.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { afterEach, beforeAll, describe, expect, test, vi } from "vitest"; -import type { TUI } from "../../tui/src/tui.ts"; -import { - createLoginMenuSelector, - createRadiusSignInSelector, -} from "../src/modes/interactive/components/radius-login-selector.ts"; -import { initTheme } from "../src/modes/interactive/theme/theme.ts"; - -const DOWN = "\x1b[B"; -const UP = "\x1b[A"; -const RADIUS_BLUE = "38;2;77;154;191m"; // #4d9abf, first Radius logo color - -function stripAnsi(text: string): string { - return text.replace(/\x1b\[[0-9;]*m/g, ""); -} - -function createMenu(requestRender = vi.fn()) { - const tui = { requestRender } as unknown as TUI; - return createLoginMenuSelector( - tui, - "Select:", - ["Sign in with an account", "Sign in with Radius ✓ configured"], - { label: "Sign in with Radius ✓ configured", text: "Sign in with Radius" }, - { onSelect: () => {}, onCancel: () => {} }, - ); -} - -describe("Radius login selectors", () => { - beforeAll(() => initTheme("dark")); - afterEach(() => vi.useRealTimers()); - - test("animates the Radius option only while it is selected", () => { - vi.useFakeTimers(); - const requestRender = vi.fn(); - const selector = createMenu(requestRender); - - expect(vi.getTimerCount()).toBe(0); - expect(selector.render(80).join("\n")).not.toContain(RADIUS_BLUE); - - selector.handleInput(DOWN); - vi.advanceTimersByTime(200); - expect(requestRender).toHaveBeenCalled(); - const first = selector.render(80).join("\n"); - expect(first).toContain(RADIUS_BLUE); - expect(stripAnsi(first)).toContain(" → Sign in with Radius ✓ configured"); - vi.advanceTimersByTime(400); - expect(selector.render(80).join("\n")).not.toBe(first); - - selector.handleInput(UP); - requestRender.mockClear(); - vi.advanceTimersByTime(500); - expect(requestRender).not.toHaveBeenCalled(); - expect(vi.getTimerCount()).toBe(0); - selector.dispose(); - }); - - test("aligns options with the title", () => { - const lines = createMenu() - .render(80) - .map((line) => stripAnsi(line).trimEnd()); - expect(lines).toContain(" Select:"); - expect(lines).toContain(" → Sign in with an account"); - expect(lines).toContain(" Sign in with Radius ✓ configured"); - }); - - test("shows the Radius intro above the sign-in method title", () => { - vi.useFakeTimers(); - const tui = { requestRender: vi.fn() } as unknown as TUI; - const selector = createRadiusSignInSelector(tui, "Sign in to Radius:", ["Browser"], { - onSelect: () => {}, - onCancel: () => {}, - }); - const lines = selector.render(80).map((line) => stripAnsi(line).trim()); - const intro = lines.indexOf("Radius is a service crafted for Pi by the builders of Pi, Earendil Works"); - expect(intro).toBeGreaterThan(-1); - expect(lines[intro + 1]).toBe(""); - expect(lines[intro + 2]).toBe("Sign in to Radius:"); - expect(vi.getTimerCount()).toBe(0); - }); -});