Something went wrong. Try again.
fork of prime agent with changes i want but are not upstreamable probqbly (and some fixes)
Something went wrong. Try again.
6.9 kB · 225 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226import { type Component, type Focusable, getKeybindings, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";import { getResolvedThemeColors, theme } from "../theme/theme.js";import { MenuSearchInput } from "./menu-panel.js";import { isOnboardingExitKey } from "./onboarding-exit.js";import { onboardingHighlightBackground } from "./onboarding-highlight.js";
export interface OnboardingPickerItem { id: string; label: string; /** Already signed in: the row is marked with a check rather than a note. */ connected?: boolean;}
interface OnboardingPickerOptions { prompt?: string; searchPlaceholder?: string; note?: string; continueLabel?: string; visibleRows?: number; rowWidth?: number; requestRender?: () => void; /** Quits the app while onboarding owns the screen. */ onExit?: () => void;}
const MARKER_WIDTH = 2;const MIN_ROW_WIDTH = 34;const ROW_TRAILING = 6;const DEFAULT_VISIBLE_ROWS = 6;
/** * A searchable list in the onboarding block: a pinned continue action, then the * matching entries in a scrolling viewport. Entries stay selectable repeatedly, * so a user can connect several providers before moving on. */export class OnboardingPickerComponent implements Component, Focusable { /** The list marks selection with its own caret, so the field hides the prompt. */ private readonly search: MenuSearchInput; private selectedIndex = 0; private scrollTop = 0; private _focused = false;
constructor( private readonly items: readonly OnboardingPickerItem[], private readonly onSelect: (id: string) => void, private readonly onContinue: () => void, private readonly onCancel: () => void, private readonly config: OnboardingPickerOptions = {}, ) { this.search = new MenuSearchInput(config.searchPlaceholder ?? "Search", true, true, true); }
get focused(): boolean { return this._focused; }
set focused(value: boolean) { this._focused = value; this.search.focused = value; }
invalidate(): void { this.search.invalidate(); }
handleInput(keyData: string): void { if (isOnboardingExitKey(keyData)) { this.config.onExit?.(); return; } const kb = getKeybindings(); if (kb.matches(keyData, "tui.select.up")) { this.move(-1); return; } if (kb.matches(keyData, "tui.select.down")) { this.move(1); return; } if (kb.matches(keyData, "tui.select.confirm")) { if (this.selectedIndex === 0) { this.onContinue(); return; } const item = this.getFiltered()[this.selectedIndex - 1]; if (item) { this.onSelect(item.id); } return; } if (kb.matches(keyData, "tui.select.cancel")) { this.onCancel(); return; } this.search.handleInput(keyData); this.selectedIndex = Math.min(this.selectedIndex, this.getFiltered().length); this.scrollTop = 0; this.config.requestRender?.(); }
render(width: number): string[] { const safeWidth = Math.max(1, width); const filtered = this.getFiltered(); const visibleRows = this.config.visibleRows ?? DEFAULT_VISIBLE_ROWS; this.clampScroll(filtered.length, visibleRows);
const rowWidth = this.getRowWidth(safeWidth); const background = onboardingHighlightBackground(getResolvedThemeColors()); const lines: string[] = [this.line(safeWidth, "")]; if (this.config.prompt) { lines.push(this.line(safeWidth, theme.fg("text", this.config.prompt))); lines.push(this.line(safeWidth, "")); } // The field renders its own single-column indent, matching the prompt above. const field = this.search.render(safeWidth)[0] ?? ""; lines.push( truncateToWidth(field, safeWidth, "", true) + " ".repeat(Math.max(0, safeWidth - visibleWidth(field))), ); lines.push(this.line(safeWidth, "")); lines.push( this.renderRow( safeWidth, rowWidth, this.config.continueLabel ?? "Continue", false, this.selectedIndex === 0, background, ), );
const end = Math.min(filtered.length, this.scrollTop + visibleRows); for (let index = this.scrollTop; index < end; index++) { const item = filtered[index]; if (!item) continue; lines.push( this.renderRow( safeWidth, rowWidth, item.label, item.connected === true, this.selectedIndex === index + 1, background, ), ); } const remaining = filtered.length - end; if (remaining > 0 || this.scrollTop > 0) { const hint = remaining > 0 ? `${remaining} more below` : "top of list"; lines.push(this.line(safeWidth, theme.fg("dim", ` ${hint}`))); } if (this.config.note) { lines.push(this.line(safeWidth, "")); lines.push(this.line(safeWidth, theme.fg("dim", this.config.note))); } return lines; }
private renderRow( width: number, rowWidth: number, label: string, connected: boolean, selected: boolean, background: (value: string) => string, ): string { const name = `${selected ? "> " : " "}${label}`; const mark = connected ? " \u2713" : ""; const pad = " ".repeat(Math.max(0, rowWidth - visibleWidth(truncateToWidth(`${name}${mark}`, rowWidth, "")))); const styledMark = connected ? theme.fg("success", " \u2713") : ""; const styled = selected ? background(theme.bold(theme.fg("text", name)) + styledMark + theme.fg("dim", pad)) : theme.fg("muted", name) + styledMark + theme.fg("dim", pad); return this.line(width, styled); }
private getRowWidth(width: number): number { const longest = this.items.reduce( (max, item) => Math.max(max, visibleWidth(item.label) + (item.connected ? 3 : 0)), 0, ); return Math.min( Math.max(1, width - 1), this.config.rowWidth ?? Math.max(MIN_ROW_WIDTH, MARKER_WIDTH + longest + ROW_TRAILING), ); }
private getFiltered(): readonly OnboardingPickerItem[] { const query = this.search.getValue().trim().toLowerCase(); if (!query) { return this.items; } return this.items.filter( (item) => item.label.toLowerCase().includes(query) || item.id.toLowerCase().includes(query), ); }
private move(delta: number): void { const filtered = this.getFiltered(); const next = this.selectedIndex + delta; if (next < 0 || next > filtered.length) { return; } this.selectedIndex = next; const visibleRows = this.config.visibleRows ?? DEFAULT_VISIBLE_ROWS; if (next >= 1) { const itemIndex = next - 1; if (itemIndex < this.scrollTop) { this.scrollTop = itemIndex; } else if (itemIndex >= this.scrollTop + visibleRows) { this.scrollTop = itemIndex - visibleRows + 1; } } this.config.requestRender?.(); }
private clampScroll(total: number, visibleRows: number): void { const maxScroll = Math.max(0, total - visibleRows); this.scrollTop = Math.max(0, Math.min(this.scrollTop, maxScroll)); }
private line(width: number, content: string): string { const truncated = truncateToWidth(content ? ` ${content}` : "", width, ""); return truncated + " ".repeat(Math.max(0, width - visibleWidth(truncated))); }}