From c117a85d7aca8c773599cb52923144f7b8bc6557 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 1 Oct 2026 15:08:57 +0200 Subject: [PATCH] refactor(coding-agent): build the Radius login shimmer on ExtensionSelectorComponent The Radius menu subclasses ExtensionSelectorComponent and only replaces the selected Radius row, instead of duplicating the selector. The Radius intro uses the existing description option. --- .../components/radius-login-selector.ts | 194 +++++------------- .../src/modes/interactive/interactive-mode.ts | 36 ++-- .../test/radius-login-selector.test.ts | 80 -------- 3 files changed, 74 insertions(+), 236 deletions(-) delete mode 100644 packages/coding-agent/test/radius-login-selector.test.ts 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); - }); -});