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.
5.7 kB · 194 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195/** * Generic selector component for extensions. * Displays a list of string options with keyboard navigation. */
import { Container, getKeybindings, Spacer, Text, type TUI } from "@earendil-works/pi-tui";import { theme } from "../theme/theme.js";import { CountdownTimer } from "./countdown-timer.js";import { keyHint, rawKeyHint } from "./keybinding-hints.js";import { getMenuListLayout, inlineMenuPanelTopRuleRows, MenuList, MenuPanel, MenuRow, type MenuViewportProvider,} from "./menu-panel.js";
export interface ExtensionSelectorOptions { tui?: TUI; timeout?: number; getRows?: () => number; inline?: boolean;}
const PREFERRED_VISIBLE_OPTIONS = 8;const OPTION_LIST_RESERVED_BASE_ROWS = 5;const OPTION_SCROLL_INDICATOR_ROWS = 1;
function splitTitleAndDescription(value: string): { title: string; description?: string; descriptionRows: number } { const lines = value .split(/\r?\n/) .map((line) => line.trim()) .filter((line) => line.length > 0); const [title = "", ...descriptionLines] = lines; return { title, description: descriptionLines.length > 0 ? descriptionLines.join("\n") : undefined, descriptionRows: descriptionLines.length, };}
export class ExtensionSelectorComponent extends Container { private options: string[]; private selectedIndex = 0; private listContainer: Container; private onSelectCallback: (option: string) => void; private onCancelCallback: () => void; private baseTitle: string; private countdown: CountdownTimer | undefined; private panel: MenuPanel; private readonly reservedRows: number; private listLayout = getMenuListLayout({ preferredVisibleItems: PREFERRED_VISIBLE_OPTIONS, reservedRows: OPTION_LIST_RESERVED_BASE_ROWS, comfortableItemRows: 1, compactItemRows: 1, }); private readonly viewport: MenuViewportProvider; private readonly inline: boolean;
constructor( title: string, options: string[], onSelect: (option: string) => void, onCancel: () => void, opts?: ExtensionSelectorOptions, ) { super();
this.options = options; this.onSelectCallback = onSelect; this.onCancelCallback = onCancel; this.inline = opts?.inline === true; const header = splitTitleAndDescription(title); this.baseTitle = header.title; const tui = opts?.tui; this.viewport = { getRows: opts?.getRows ?? (tui ? () => tui.terminal.rows : undefined) };
this.panel = new MenuPanel({ title: header.title, subtitle: header.description, inline: this.inline, }); this.addChild(this.panel);
if (opts?.timeout && opts.timeout > 0 && opts.tui) { this.countdown = new CountdownTimer( opts.timeout, opts.tui, (s) => this.panel.setTitle(`${this.baseTitle} (${s}s)`), () => this.onCancelCallback(), ); }
this.listContainer = new MenuList({ compact: true, inline: this.inline }); this.panel.addChild(this.listContainer); // The inline panel opens with its separator rule; budget it. this.reservedRows = OPTION_LIST_RESERVED_BASE_ROWS + header.descriptionRows + (this.inline ? inlineMenuPanelTopRuleRows({ title: header.title, subtitle: header.description, firstChild: this.listContainer, }) : 0); if (!this.inline) this.panel.addChild(new Spacer(1)); this.panel.addChild( new Text( rawKeyHint("↑↓", "navigate") + " " + keyHint("tui.select.confirm", "select") + " " + keyHint("tui.select.cancel", "cancel"), 1, 0, ), );
this.updateList(); }
override render(width: number): string[] { const previousLayout = this.listLayout; this.updateLayout(); if ( this.listLayout.compact !== previousLayout.compact || this.listLayout.visibleItems !== previousLayout.visibleItems ) { this.updateList(); } return super.render(width); }
private updateList(): void { this.updateLayout(); this.listContainer.clear(); const maxVisible = this.listLayout.visibleItems; const startIndex = Math.max( 0, Math.min(this.selectedIndex - Math.floor(maxVisible / 2), this.options.length - maxVisible), ); const endIndex = Math.min(startIndex + maxVisible, this.options.length); for (let i = startIndex; i < endIndex; i++) { const isSelected = i === this.selectedIndex; this.listContainer.addChild( new MenuRow({ primary: this.options[i] ?? "", selected: isSelected, inline: this.inline, }), ); } if (startIndex > 0 || endIndex < this.options.length) { this.listContainer.addChild( new Text(theme.fg("muted", ` (${this.selectedIndex + 1}/${this.options.length})`), 0, 0), ); } }
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.updateList(); } else if (kb.matches(keyData, "tui.select.down") || keyData === "j") { this.selectedIndex = Math.min(this.options.length - 1, this.selectedIndex + 1); this.updateList(); } else if (kb.matches(keyData, "tui.select.confirm") || keyData === "\n") { const selected = this.options[this.selectedIndex]; if (selected) this.onSelectCallback(selected); } else if (kb.matches(keyData, "tui.select.cancel")) { this.onCancelCallback(); } }
dispose(): void { this.countdown?.dispose(); }
private updateLayout(): void { this.listLayout = getMenuListLayout({ getRows: this.viewport.getRows, preferredVisibleItems: PREFERRED_VISIBLE_OPTIONS, totalItems: this.options.length, reservedRows: this.reservedRows, comfortableItemRows: 1, compactItemRows: 1, scrollIndicatorRows: OPTION_SCROLL_INDICATOR_ROWS, }); }}