Something went wrong. Try again.
Shared settings UI and config loader for pi extensions
Something went wrong. Try again.
6.8 kB · 239 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240/** * A multi-step wizard component with tabbed navigation and bordered frame. * * Features: * - Box-drawing border around the entire wizard (╭╮╰╯ style) * - Step tabs at the top with progress indicators (●/○) * - Tab/Shift+Tab navigation between steps * - Each step renders its own inner Component * - Ctrl+S or Enter on the last step submits * - Esc cancels the entire wizard * * Use for first-time setup flows, multi-step configuration, or any * sequential data collection that benefits from back-and-forth navigation. */
import { Panel } from "@aliou/pi-utils-ui";import type { Theme } from "@earendil-works/pi-coding-agent";import type { Component } from "@earendil-works/pi-tui";import { Key, matchesKey } from "@earendil-works/pi-tui";
class RenderLines implements Component { constructor(private renderLines: (width: number) => string[]) {}
render(width: number): string[] { return this.renderLines(width); }
invalidate(): void {}}
export interface WizardStep { /** Tab label shown at the top. Keep it short (1-2 words). */ label: string; /** * Build the inner component for this step. * * The component controls its own rendering and input handling. * Call `markComplete()` to mark the step as done (shows ● in tabs). * Call `markIncomplete()` to revert it (shows ○). * * The wizard handles borders, tabs, and step navigation externally. * The inner component should NOT handle Tab/Shift+Tab/Esc/Ctrl+S. */ build: (ctx: WizardStepContext) => Component;}
export interface WizardStepContext { /** Mark this step as complete (filled ● in progress). */ markComplete: () => void; /** Mark this step as incomplete (empty ○ in progress). */ markIncomplete: () => void; /** Advance to the next step (wraps around). */ goNext: () => void; /** Go back to the previous step (wraps around). */ goPrev: () => void;}
export interface WizardOptions { /** Title shown at the top of the wizard. */ title: string; /** Ordered list of steps. */ steps: WizardStep[]; /** Theme for rendering borders and chrome. */ theme: Theme; /** Called when user submits (Ctrl+S). */ onComplete: () => void; /** Called when user cancels (Esc). */ onCancel: () => void; /** Hint text appended to the controls line. */ hintSuffix?: string; /** * Minimum number of lines for the inner step content area. * If a step renders fewer lines, the wizard pads with blanks. * This keeps the wizard height stable across tabs. */ minContentHeight?: number;}
export class Wizard implements Component { private steps: WizardStep[]; private theme: Theme; private onComplete: () => void; private onCancel: () => void; private title: string; private hintSuffix: string;
private activeIndex = 0; private completed: boolean[]; private components: Component[]; private minContentHeight: number;
constructor(options: WizardOptions) { this.steps = options.steps; this.theme = options.theme; this.onComplete = options.onComplete; this.onCancel = options.onCancel; this.title = options.title; this.hintSuffix = options.hintSuffix ?? ""; this.minContentHeight = options.minContentHeight ?? 0;
this.completed = new Array(options.steps.length).fill(false) as boolean[]; this.components = options.steps.map((step, i) => step.build({ markComplete: () => { this.completed[i] = true; }, markIncomplete: () => { this.completed[i] = false; }, goNext: () => { this.activeIndex = (this.activeIndex + 1) % this.steps.length; }, goPrev: () => { this.activeIndex = (this.activeIndex - 1 + this.steps.length) % this.steps.length; }, }), ); }
/** Returns the index of the currently active step. */ getActiveIndex(): number { return this.activeIndex; }
/** Returns whether all steps are marked complete. */ isAllComplete(): boolean { return this.completed.every(Boolean); }
invalidate(): void { for (const component of this.components) { component.invalidate?.(); } }
render(width: number): string[] { const t = this.theme;
const body = new RenderLines((contentWidth) => { const lines: string[] = [];
lines.push(this.renderStepTabs(contentWidth)); lines.push("");
const innerComponent = this.components[this.activeIndex]; const innerLines = innerComponent ? innerComponent.render(contentWidth) : []; lines.push(...innerLines); for (let i = innerLines.length; i < this.minContentHeight; i++) { lines.push(""); }
return lines; });
const footer = new RenderLines(() => [this.buildControlsText()]);
return new Panel({ title: this.title, body, footer, border: "round", borderStyle: (text) => t.fg("border", text), titleStyle: (text) => t.fg("accent", t.bold(text)), padding: 1, }).render(width); }
handleInput(data: string): void { // Ctrl+S: submit if all steps complete if (matchesKey(data, Key.ctrl("s"))) { this.onComplete(); return; }
// Esc: cancel if (matchesKey(data, Key.escape)) { this.onCancel(); return; }
// Tab / Shift+Tab: navigate steps if (matchesKey(data, Key.tab)) { this.activeIndex = (this.activeIndex + 1) % this.steps.length; return; } if (matchesKey(data, Key.shift("tab"))) { this.activeIndex = (this.activeIndex - 1 + this.steps.length) % this.steps.length; return; }
// Delegate to inner component const innerComponent = this.components[this.activeIndex]; innerComponent?.handleInput?.(data); }
// --- Private rendering helpers ---
private renderStepTabs(_contentWidth: number): string { const t = this.theme; const parts: string[] = [];
for (let i = 0; i < this.steps.length; i++) { const step = this.steps[i]; if (!step) continue;
const dot = this.completed[i] ? t.fg("success", "●") : i === this.activeIndex ? t.fg("accent", "●") : t.fg("dim", "○");
const label = i === this.activeIndex ? t.bg("selectedBg", t.fg("accent", ` ${step.label} `)) : t.fg("dim", ` ${step.label} `);
parts.push(`${dot} ${label}`); }
return parts.join(" "); }
private buildControlsText(): string { const t = this.theme; const parts: string[] = [ "Tab/Shift+Tab navigate", "Ctrl+S submit", "Esc cancel", ]; if (this.hintSuffix) { parts.push(this.hintSuffix); } return t.fg("dim", ` ${parts.join(" · ")}`); }}