import { 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, 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, 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; 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; } }