mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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.
This commit is contained in:
parent
8847e9d6f1
commit
c117a85d7a
3 changed files with 74 additions and 236 deletions
|
|
@ -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<typeof setInterval> | 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<AuthSelectorProvider["authType"], number>;
|
||||
|
||||
function createFuzzyAutocompleteItems<T>(
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue