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.
21 kB · 627 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628import { type Component, Container, type Focusable, Input, truncateToWidth, visibleWidth, wrapTextWithAnsi,} from "@earendil-works/pi-tui";import { theme } from "../theme/theme.js";
interface MenuPanelOptions { title: string; subtitle?: string; inline?: boolean; /** * Inline only: force or suppress the full-width separator rule above the * panel. By default an inline panel opens with exactly ONE rule — its own, * or the bordered search input's when that input already leads the panel * (never both). */ topRule?: boolean;}
/** * A component whose first rendered line is already a full-width rule: the * inline MenuSearchInput. MenuPanel uses this to open every inline picker with * exactly one separator rule instead of doubling the search input's border. */interface InlineTopRuleComponent { readonly rendersInlineTopRule: boolean;}
function rendersInlineTopRule(component: Component | undefined): boolean { return (component as InlineTopRuleComponent | undefined)?.rendersInlineTopRule === true;}
/** * The child whose first line opens the panel body. Children that render nothing * (an empty placeholder Container, e.g. the model picker's header-help slot * before it is populated) must be skipped: treating one as the opener answered * "the search input does not lead" and drew a panel rule directly on top of the * search box's own border — two stacked rules and one wasted list row. */function firstRenderingChild(children: readonly Component[]): Component | undefined { return children.find((child) => !(child instanceof Container) || child.children.length > 0);}
/** * Rows an inline MenuPanel draws above its children: one separator rule, * except when the bordered search input already leads the panel and its own * top border IS that rule. Components that budget viewport rows for an inline * panel must add this to their reserved rows — it is the same decision * MenuPanel.render applies, so the budget and the frame can never disagree. */export function inlineMenuPanelTopRuleRows(options: { title?: string; subtitle?: string; /** Prefer `children`: an empty placeholder child must not count as the opener. */ firstChild?: Component; children?: readonly Component[]; topRule?: boolean;}): number { const hasHeader = Boolean(options.title) || Boolean(options.subtitle?.trim()); const opener = options.children ? firstRenderingChild(options.children) : options.firstChild; const firstChildLeadsWithRule = rendersInlineTopRule(opener); return (options.topRule ?? (!firstChildLeadsWithRule || hasHeader)) ? 1 : 0;}
export interface MenuViewportProvider { getRows?: () => number;}
interface MenuListOptions { compact?: boolean | (() => boolean); inline?: boolean;}
interface MenuListLayoutOptions extends MenuViewportProvider { preferredVisibleItems: number; minVisibleItems?: number; totalItems?: number; reservedRows: number; comfortableItemRows: number; compactItemRows?: number; scrollIndicatorRows?: number; comfortableListPaddingRows?: number; compactListPaddingRows?: number;}
export interface MenuListLayout { compact: boolean; visibleItems: number;}
const PANEL_PADDING_X = 2;const PANEL_PADDING_Y = 1;const FIELD_PADDING_X = 2;const ROW_PADDING_X = 2;const ROW_PADDING_Y = 1;const ANSI_RESET = "\x1b[0m";
export function getMenuPanelInnerWidth(width: number, inline = false): number { const padding = inline ? 1 : PANEL_PADDING_X; return Math.max(1, width - padding * 2);}
interface FullWidthMenuComponent { readonly fillsMenuPanel: true;}
function fillsMenuPanel(component: Component): component is Component & FullWidthMenuComponent { return (component as { fillsMenuPanel?: unknown }).fillsMenuPanel === true;}
function getViewportRows(getRows: (() => number) | undefined): number | undefined { const rows = getRows?.(); if (rows === undefined || !Number.isFinite(rows) || rows <= 0) { return undefined; } return Math.floor(rows);}
function visibleItemCount( rows: number, options: { preferredVisibleItems: number; minVisibleItems: number; reservedRows: number; itemRows: number; listPaddingRows: number; extraRows: number; },): number { const capacityRows = Math.max(0, rows - options.reservedRows - options.listPaddingRows - options.extraRows); const itemCapacity = Math.floor(capacityRows / options.itemRows); return Math.max(options.minVisibleItems, Math.min(options.preferredVisibleItems, itemCapacity));}
function listRowsUsed(options: { reservedRows: number; listPaddingRows: number; visibleItems: number; itemRows: number; extraRows: number;}): number { return options.reservedRows + options.listPaddingRows + options.extraRows + options.visibleItems * options.itemRows;}
function scrollIndicatorRows(options: { totalItems: number | undefined; visibleItems: number; scrollIndicatorRows: number;}): number { if (options.totalItems === undefined || options.scrollIndicatorRows <= 0) { return 0; } return options.totalItems > options.visibleItems ? options.scrollIndicatorRows : 0;}
function getLayoutCandidate( rows: number, options: MenuListLayoutOptions, itemRows: number, listPaddingRows: number, compact: boolean,): MenuListLayout & { rowsUsed: number; fits: boolean } { const minVisibleItems = options.minVisibleItems ?? 1; const preferredVisibleItems = Math.max(minVisibleItems, options.preferredVisibleItems); const visibleItemsWithoutScroll = visibleItemCount(rows, { preferredVisibleItems, minVisibleItems, reservedRows: options.reservedRows, itemRows, listPaddingRows, extraRows: 0, }); const extraRows = scrollIndicatorRows({ totalItems: options.totalItems, visibleItems: visibleItemsWithoutScroll, scrollIndicatorRows: options.scrollIndicatorRows ?? 0, }); const visibleItems = extraRows > 0 ? visibleItemCount(rows, { preferredVisibleItems, minVisibleItems, reservedRows: options.reservedRows, itemRows, listPaddingRows, extraRows, }) : visibleItemsWithoutScroll; const rowsUsed = listRowsUsed({ reservedRows: options.reservedRows, listPaddingRows, visibleItems, itemRows, extraRows, }); return { compact, visibleItems, rowsUsed, fits: rowsUsed <= rows, };}
export function getMenuListLayout(options: MenuListLayoutOptions): MenuListLayout { const minVisibleItems = options.minVisibleItems ?? 1; const preferredVisibleItems = Math.max(minVisibleItems, options.preferredVisibleItems); const rows = getViewportRows(options.getRows); if (rows === undefined) { return { compact: false, visibleItems: preferredVisibleItems }; }
const comfortableLayout = getLayoutCandidate( rows, options, Math.max(1, options.comfortableItemRows), options.comfortableListPaddingRows ?? 1, false, ); if (options.compactItemRows === undefined) { return { compact: false, visibleItems: comfortableLayout.visibleItems }; }
const compactLayout = getLayoutCandidate( rows, options, Math.max(1, options.compactItemRows), options.compactListPaddingRows ?? 0, true, ); if (compactLayout.fits && (!comfortableLayout.fits || compactLayout.visibleItems > comfortableLayout.visibleItems)) { return { compact: true, visibleItems: compactLayout.visibleItems }; } if (comfortableLayout.fits) { return { compact: false, visibleItems: comfortableLayout.visibleItems }; } return compactLayout.rowsUsed <= comfortableLayout.rowsUsed ? { compact: true, visibleItems: compactLayout.visibleItems } : { compact: false, visibleItems: comfortableLayout.visibleItems };}
function reduceInlineTrailingSegments(segments: ReadonlyArray<string>, budget: number): string[] { let current = segments.filter((segment) => segment.length > 0); while (current.length > 1 && visibleWidth(current.join(" · ")) > budget) { current = current.slice(1); } return current;}
/** * Rendered width of a trailing cluster at the given row width, mirroring how * MenuRow degrades and truncates it. Pickers use this to budget row content. */export function getInlineTrailingWidth(segments: ReadonlyArray<string>, width: number): number { const innerWidth = Math.max(1, width - 2); const budget = Math.max(1, innerWidth - 5); const reduced = reduceInlineTrailingSegments(segments, budget); if (reduced.length === 0) return 0; return Math.min(visibleWidth(reduced.join(" · ")), budget);}
function paddedBackgroundLine( text: string, width: number, paddingX: number, background: ((text: string) => string) | undefined,): string { const innerWidth = Math.max(1, width - paddingX * 2); const content = truncateToWidth(text, innerWidth, ""); const rightPadding = " ".repeat(Math.max(0, innerWidth - visibleWidth(content))); const contentSpan = " ".repeat(paddingX) + content; const trailingSpan = rightPadding + " ".repeat(paddingX); if (!background) { return contentSpan + trailingSpan; } return applyBackground(contentSpan, background) + background(trailingSpan);}
function applyBackground(text: string, background: (text: string) => string): string { return text .split(ANSI_RESET) .map((segment) => background(segment)) .join(ANSI_RESET);}
function surfaceLine(text: string, width: number, paddingX = PANEL_PADDING_X): string { return paddedBackgroundLine(text, width, paddingX, theme.getEditorBackgroundColor());}
function surfaceWrappedLines(text: string, width: number, paddingX = PANEL_PADDING_X): string[] { const innerWidth = Math.max(1, width - paddingX * 2); return wrapTextWithAnsi(text, innerWidth).map((content) => surfaceLine(content, width, paddingX));}
export class MenuPanel extends Container { private title: string;
constructor(private readonly options: MenuPanelOptions) { super(); this.title = options.title; }
setTitle(title: string): void { this.title = title; }
override render(width: number): string[] { if (this.options.inline) { const lines: string[] = []; // Every inline picker opens with one full-width rule that separates // it from the transcript above. A headerless panel led by the // bordered search input keeps that input's own top border as the // rule; a panel with a title or subtitle draws the rule above it. if ( inlineMenuPanelTopRuleRows({ title: this.title, subtitle: this.options.subtitle, children: this.children, topRule: this.options.topRule, }) > 0 ) { lines.push(theme.fg("borderMuted", "─".repeat(Math.max(0, width)))); } if (this.title) lines.push(theme.fg("muted", ` ${this.title}`)); const subtitle = this.options.subtitle?.trim(); if (subtitle) { for (const line of wrapTextWithAnsi(subtitle, getMenuPanelInnerWidth(width, true))) { lines.push(` ${theme.fg("muted", line)}`); } } for (const child of this.children) { lines.push( ...child .render(fillsMenuPanel(child) ? width : getMenuPanelInnerWidth(width, true)) .map((line) => (fillsMenuPanel(child) ? line : ` ${line}`)), ); } return lines.map((line) => truncateToWidth(line, width, "", true)); } const safeWidth = Math.max(PANEL_PADDING_X * 2 + 1, width); const innerWidth = getMenuPanelInnerWidth(width); const lines: string[] = [];
for (let i = 0; i < PANEL_PADDING_Y; i++) { lines.push(surfaceLine("", safeWidth)); } const hasTitle = this.title.trim().length > 0; const subtitle = this.options.subtitle?.trim(); const hasSubtitle = subtitle !== undefined && subtitle.length > 0; const hasHeader = hasTitle || hasSubtitle; if (hasTitle) { lines.push(surfaceLine(theme.fg("text", this.title), safeWidth)); } if (hasSubtitle) { lines.push(...surfaceWrappedLines(theme.fg("muted", subtitle), safeWidth)); } if (hasHeader) { lines.push(surfaceLine("", safeWidth)); }
for (const child of this.children) { const childLines = fillsMenuPanel(child) ? child.render(safeWidth) : child.render(innerWidth); for (const line of childLines) { lines.push(fillsMenuPanel(child) ? line : surfaceLine(line, safeWidth)); } }
for (let i = 0; i < PANEL_PADDING_Y; i++) { lines.push(surfaceLine("", safeWidth)); } return lines; }}
export class MenuSearchInput implements Component, Focusable, FullWidthMenuComponent { readonly fillsMenuPanel = true; private readonly input: Input;
constructor( private readonly placeholder: string, private readonly inline = false, /** Inline only: drop the enclosing rules and render just the field. */ private readonly plain = false, /** Drop the "> " prompt for surfaces that mark selection with their own caret. */ private readonly hidePrompt = false, options: { masked?: boolean } = {}, ) { this.input = new Input(options.masked === true ? { masked: true } : {}); }
/** The inline variant renders a full-width rule as its first line — unless it renders plain. */ get rendersInlineTopRule(): boolean { return this.inline && !this.plain; }
get focused(): boolean { return this.input.focused; }
set focused(value: boolean) { this.input.focused = value; }
set onSubmit(handler: ((value: string) => void) | undefined) { this.input.onSubmit = handler; }
getValue(): string { return this.input.getValue(); }
getCursor(): number { return this.input.getCursor(); }
setValue(value: string): void { this.input.setValue(value); }
handleInput(data: string): void { this.input.handleInput(data); }
invalidate(): void { this.input.invalidate(); }
render(width: number): string[] { if (this.inline) { let content = this.input.render(Math.max(1, width - 2))[0] ?? ""; if (this.hidePrompt) { content = this.stripInputPrompt(content); } if (this.getValue() === "") { const placeholder = theme.fg("dim", this.placeholder); if (this.hidePrompt) { // Sit the caret on the first placeholder character so the field keeps // the same left edge as the text above it. content = this.focused ? `\x1b[7m${this.placeholder.slice(0, 1)}\x1b[27m${theme.fg("dim", this.placeholder.slice(1))}` : placeholder; } else { content = this.focused ? `${content.trimEnd()}${placeholder}` : `> ${placeholder}`; } } const field = truncateToWidth(` ${content}`, width, "", true); if (this.plain) { return [field]; } const border = theme.fg("borderMuted", "─".repeat(Math.max(0, width))); return [border, field, border]; } const safeWidth = Math.max(FIELD_PADDING_X * 2 + 1, width); const innerWidth = Math.max(1, safeWidth - FIELD_PADDING_X * 2); const content = this.getValue() === "" && !this.focused ? theme.fg("dim", this.placeholder) : this.stripInputPrompt(this.input.render(innerWidth + 2)[0] ?? ""); return [paddedBackgroundLine(content, safeWidth, FIELD_PADDING_X, theme.getEditorBackgroundColor())]; }
private stripInputPrompt(line: string): string { return line.startsWith("> ") ? line.slice(2) : line; }}
interface MenuRowOptions { primary: string; secondary?: string; meta?: string; /** * Inline-only segments rendered right-aligned; the last segment sits flush against the * row's right edge. Earlier segments drop first when the row is too narrow. */ trailing?: ReadonlyArray<string>; selected: boolean; inline?: boolean;}
export class MenuRow implements Component, FullWidthMenuComponent { readonly fillsMenuPanel = true;
constructor(private readonly options: MenuRowOptions) {}
get selected(): boolean { return this.options.selected; }
invalidate(): void { // Row render is derived from constructor options. }
render(width: number): string[] { const safeWidth = Math.max(ROW_PADDING_X * 2 + 1, width); return [ ...this.renderPadding(safeWidth, this.selected), ...this.renderContent(safeWidth), ...this.renderPadding(safeWidth, this.selected), ]; }
renderContent(width: number): string[] { if (this.options.inline) { // Trailing rows run flush to the right edge; legacy rows keep a one-column margin. const hasTrailing = this.options.trailing !== undefined; const innerWidth = Math.max(1, hasTrailing ? width - 2 : width - 3); const trailing = this.getInlineTrailing(width, innerWidth); const trailingWidth = visibleWidth(trailing); const gap = trailingWidth > 0 ? 2 : 0; const primaryWidth = Math.max(1, innerWidth - trailingWidth - gap); const primaryText = theme.fg("text", this.options.primary); const primary = truncateToWidth( this.selected ? theme.bold(primaryText) : primaryText, primaryWidth, "…", true, ); const filler = " ".repeat(Math.max(0, innerWidth - visibleWidth(primary) - trailingWidth)); const content = `${this.selected ? "›" : " "} ${primary}${filler}${trailing}`; return [ paddedBackgroundLine( content, width, 0, this.selected ? theme.getSoftSelectionBackgroundColor() : undefined, ), ]; } const safeWidth = Math.max(ROW_PADDING_X * 2 + 1, width); const meta = this.options.meta ? theme.fg("muted", this.options.meta) : ""; const secondary = this.options.secondary ? theme.fg("muted", this.options.secondary) : ""; const primary = this.options.selected ? theme.bold(theme.fg("text", this.options.primary)) : theme.fg("text", this.options.primary); const innerWidth = Math.max(1, safeWidth - ROW_PADDING_X * 2); const metaWidth = visibleWidth(meta); const gap = meta ? 2 : 0; const primaryWidth = Math.max(1, innerWidth - metaWidth - gap); const primaryText = truncateToWidth(primary, primaryWidth, "", true); const primaryLine = meta ? primaryText + " ".repeat(gap) + meta : primaryText; const lines: string[] = []; lines.push(this.rowLine(primaryLine, safeWidth, this.selected)); if (secondary) { lines.push(this.rowLine(truncateToWidth(secondary, innerWidth, "", true), safeWidth, this.selected)); } return lines; }
renderPadding(width: number, selected: boolean): string[] { const safeWidth = Math.max(ROW_PADDING_X * 2 + 1, width); const lines: string[] = []; for (let i = 0; i < ROW_PADDING_Y; i++) { lines.push(this.rowLine("", safeWidth, selected)); } return lines; }
private rowLine(text: string, width: number, selected: boolean): string { const background = selected ? theme.getSoftSelectionBackgroundColor() : theme.getEditorBackgroundColor(); return paddedBackgroundLine(text, width, ROW_PADDING_X, background); }
private getInlineTrailing(width: number, innerWidth: number): string { if (this.options.trailing === undefined) { const secondary = width >= 60 ? this.options.secondary : undefined; const details = [secondary, this.options.meta].filter(Boolean).join(" · "); return details ? truncateToWidth(theme.fg("muted", details), Math.floor(innerWidth / 2), "…") : ""; } const budget = Math.max(1, innerWidth - 5); const segments = reduceInlineTrailingSegments(this.options.trailing, budget); if (segments.length === 0) return ""; return truncateToWidth(theme.fg("muted", segments.join(" · ")), budget, "…"); }}
export class MenuList extends Container implements FullWidthMenuComponent { readonly fillsMenuPanel = true;
constructor(private readonly options: MenuListOptions = {}) { super(); }
override render(width: number): string[] { if (this.options.inline) { return this.children.flatMap((child) => child instanceof MenuRow ? child.renderContent(width) : child.render(width), ); } const lines: string[] = []; const compact = this.isCompact(); for (let index = 0; index < this.children.length; index++) { const child = this.children[index]; if (child instanceof MenuRow) { if (compact) { lines.push(...child.renderContent(width)); continue; } const previousChild = this.children[index - 1]; const nextChild = this.children[index + 1]; const previousRow = previousChild instanceof MenuRow ? previousChild : undefined; const nextRow = nextChild instanceof MenuRow ? nextChild : undefined; lines.push(...child.renderPadding(width, child.selected || previousRow?.selected === true)); lines.push(...child.renderContent(width)); if (!nextRow) { lines.push(...child.renderPadding(width, child.selected)); } continue; } const childLines = fillsMenuPanel(child) ? child.render(width) : child.render(Math.max(1, width - PANEL_PADDING_X * 2)); for (const line of childLines) { lines.push(fillsMenuPanel(child) ? line : surfaceLine(line, width)); } } return lines; }
private isCompact(): boolean { const compact = this.options.compact; return typeof compact === "function" ? compact() : compact === true; }}