/** * Single owner of how a pi-amq overlay is sized and framed. * * `ctx.ui.custom` resolves overlay geometry from `overlayOptions`, falling back to the * component's own `width` and finally to `undefined`. An overlay with no geometry is composited * straight over the conversation, so its lines interleave with the chat and become unreadable. * Every pi-amq overlay therefore declares geometry and draws its own frame. */ import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui"; export const OVERLAY_OPTIONS = { width: "78%", minWidth: 72, maxHeight: "78%", anchor: "top-center", margin: { top: 1, left: 2, right: 2 }, } as const; export function truncate(value: string, width: number): string { return truncateToWidth(value, Math.max(1, width), "…"); } function padVisible(value: string, width: number, fill = " "): string { return value + fill.repeat(Math.max(0, width - visibleWidth(value))); } /** Convert logical strings into terminal-safe physical rows before framing or clipping. */ export function wrapOverlayLines(lines: readonly string[], width: number): string[] { const physicalWidth = Math.max(1, width); return lines.flatMap((line) => wrapTextWithAnsi(line.replace(/\t/g, " "), physicalWidth)); } export interface BorderedSection { readonly label?: string; readonly lines: readonly string[]; } /** Frames content so the overlay reads as its own surface rather than as text over the chat. */ export function borderedLines( title: string, lines: readonly string[], width: number, ): string[] { return borderedSections(title, [{ lines }], width); } /** Frames labeled sections with full-width rules while preserving exact terminal row widths. */ export function borderedSections( title: string, sections: readonly BorderedSection[], width: number, ): string[] { const contentWidth = Math.max(1, width - 4); const borderInnerWidth = contentWidth + 2; const topTitle = truncate(` ${title} `, borderInnerWidth); const top = `╭${padVisible(topTitle, borderInnerWidth, "─")}╮`; const bottom = `╰${"─".repeat(borderInnerWidth)}╯`; const rows = sections.flatMap((section, index) => { const physicalLines = wrapOverlayLines(section.lines, contentWidth); const rule = index === 0 ? [] : [sectionRule(section.label, borderInnerWidth)]; return [ ...rule, ...physicalLines.map((line) => { const content = truncate(line, contentWidth); return `│ ${padVisible(content, contentWidth)} │`; }), ]; }); return [ top, ...rows, bottom, ]; } function sectionRule(label: string | undefined, width: number): string { const title = label ? truncate(` ${label} `, width) : ""; return `├${padVisible(title, width, "─")}┤`; }