import type { ExtensionContext, Theme, } from "@earendil-works/pi-coding-agent"; import { matchesKey, truncateToWidth, visibleWidth, type Component, } from "@earendil-works/pi-tui"; export type TextOverlayContent = | string | string[] | ((theme: Theme) => string | string[]); export type TextOverlayOptions = { title: string; content: TextOverlayContent; subtitle?: string; footer?: string; width?: number | `${number}%`; maxHeight?: number | `${number}%`; /** * Number of content rows visible inside the panel. * Scrolling is enabled when content exceeds this value. */ visibleLines?: number; paddingX?: number; margin?: number; }; type ScrollableTextOverlayOptions = { title: string; subtitle: string; lines: string[]; footer: string; visibleLines: number; paddingX: number; onClose: () => void; onChange: () => void; }; /** * Truncates and pads an ANSI-formatted terminal string to an exact * visible width. * * Do not replace visibleWidth() with string.length because ANSI escape * sequences do not occupy terminal columns. */ function fitLine(value: string, width: number): string { const targetWidth = Math.max(0, Math.floor(width)); if (targetWidth === 0) { return ""; } const fitted = visibleWidth(value) > targetWidth ? truncateToWidth(value, targetWidth) : value; const remaining = targetWidth - visibleWidth(fitted); return fitted + " ".repeat(Math.max(0, remaining)); } function normalizeContent( content: string | string[], ): string[] { const values = Array.isArray(content) ? content : content.split("\n"); /* * Preserve empty lines because callers may use them for spacing. * Split embedded newlines in array entries as well. */ return values.flatMap((value) => String(value ?? "").split("\n"), ); } class ScrollableTextOverlay implements Component { private scrollOffset = 0; private readonly title: string; private readonly subtitle: string; private readonly lines: string[]; private readonly footer: string; private readonly visibleLines: number; private readonly paddingX: number; private readonly onClose: () => void; private readonly onChange: () => void; constructor(options: ScrollableTextOverlayOptions) { this.title = options.title; this.subtitle = options.subtitle; this.lines = options.lines; this.footer = options.footer; this.visibleLines = Math.max( 1, Math.floor(options.visibleLines), ); this.paddingX = Math.max( 0, Math.floor(options.paddingX), ); this.onClose = options.onClose; this.onChange = options.onChange; } render(width: number): string[] { /* * The width supplied by Pi is the full overlay width. Every returned * row must occupy exactly this visible width or overlay compositing * can leave artifacts behind. */ const panelWidth = Math.max(20, Math.floor(width)); const innerWidth = Math.max(1, panelWidth - 2); const maxOffset = this.getMaxOffset(); this.scrollOffset = clamp( this.scrollOffset, 0, maxOffset, ); const endIndex = Math.min( this.scrollOffset + this.visibleLines, this.lines.length, ); const visibleLines = this.lines.slice( this.scrollOffset, endIndex, ); const hasScrollbar = maxOffset > 0; /* * Content row layout: * * │ + left padding + content + gutter + scrollbar + right padding + │ * * The gutter and scrollbar each consume one terminal column. */ const scrollbarWidth = hasScrollbar ? 2 : 0; const contentWidth = Math.max( 1, innerWidth - this.paddingX * 2 - scrollbarWidth, ); const rows: string[] = [ this.renderTopBorder(panelWidth), ]; if (this.subtitle) { rows.push( this.renderPlainRow( this.subtitle, panelWidth, ), ); rows.push(this.renderSeparator(panelWidth)); } for ( let viewportIndex = 0; viewportIndex < this.visibleLines; viewportIndex += 1 ) { const value = visibleLines[viewportIndex] ?? ""; rows.push( this.renderContentRow({ value, viewportIndex, panelWidth, contentWidth, maxOffset, hasScrollbar, }), ); } rows.push(this.renderSeparator(panelWidth)); const range = hasScrollbar && this.lines.length > 0 ? ` ${this.scrollOffset + 1}–${endIndex} of ${this.lines.length}` : ""; rows.push( this.renderPlainRow( `${this.footer}${range}`, panelWidth, ), ); rows.push(this.renderBottomBorder(panelWidth)); return rows; } handleInput(input: string): void { const maxOffset = this.getMaxOffset(); let nextOffset = this.scrollOffset; if (matchesKey(input, "escape")) { this.onClose(); return; } if (matchesKey(input, "up")) { nextOffset -= 1; } else if (matchesKey(input, "down")) { nextOffset += 1; } else if (matchesKey(input, "pageUp")) { nextOffset -= this.visibleLines; } else if (matchesKey(input, "pageDown")) { nextOffset += this.visibleLines; } else if (matchesKey(input, "home")) { nextOffset = 0; } else if (matchesKey(input, "end")) { nextOffset = maxOffset; } else { return; } const clampedOffset = clamp( nextOffset, 0, maxOffset, ); if (clampedOffset === this.scrollOffset) { return; } this.scrollOffset = clampedOffset; this.onChange(); } invalidate(): void { // No cached rendering state. } private getMaxOffset(): number { return Math.max( 0, this.lines.length - this.visibleLines, ); } private renderTopBorder(panelWidth: number): string { const innerWidth = panelWidth - 2; if (!this.title) { return `╭${"─".repeat(innerWidth)}╮`; } /* * Layout: * ╭─ Title ─────────╮ */ const maximumTitleWidth = Math.max( 1, innerWidth - 3, ); const title = truncateToWidth( this.title, maximumTitleWidth, ); const titleSegment = ` ${title} `; const usedWidth = 1 + visibleWidth(titleSegment); const trailingWidth = Math.max( 0, innerWidth - usedWidth, ); return ( "╭─" + titleSegment + "─".repeat(trailingWidth) + "╮" ); } private renderSeparator(panelWidth: number): string { return `├${"─".repeat(panelWidth - 2)}┤`; } private renderBottomBorder(panelWidth: number): string { return `╰${"─".repeat(panelWidth - 2)}╯`; } private renderPlainRow( value: string, panelWidth: number, ): string { const innerWidth = Math.max( 1, panelWidth - 2, ); const availableWidth = Math.max( 1, innerWidth - this.paddingX * 2, ); const padding = " ".repeat(this.paddingX); return ( "│" + padding + fitLine(value, availableWidth) + padding + "│" ); } private renderContentRow(options: { value: string; viewportIndex: number; panelWidth: number; contentWidth: number; maxOffset: number; hasScrollbar: boolean; }): string { const { value, viewportIndex, contentWidth, maxOffset, hasScrollbar, } = options; const padding = " ".repeat(this.paddingX); const content = fitLine(value, contentWidth); const scrollbar = hasScrollbar ? ` ${this.getScrollbarCharacter( viewportIndex, maxOffset, )}` : ""; return ( "│" + padding + content + scrollbar + padding + "│" ); } private getScrollbarCharacter( viewportIndex: number, maxOffset: number, ): string { if (maxOffset <= 0 || this.lines.length === 0) { return " "; } const thumbSize = Math.max( 1, Math.floor( (this.visibleLines / this.lines.length) * this.visibleLines, ), ); const availableTravel = Math.max( 0, this.visibleLines - thumbSize, ); const thumbStart = Math.round( (this.scrollOffset / maxOffset) * availableTravel, ); const thumbEnd = thumbStart + thumbSize; return viewportIndex >= thumbStart && viewportIndex < thumbEnd ? "█" : "│"; } } function clamp( value: number, minimum: number, maximum: number, ): number { return Math.min( maximum, Math.max(minimum, value), ); } export async function showTextOverlay( ctx: ExtensionContext, options: TextOverlayOptions, ): Promise { const { title, subtitle = "", content, footer = "↑/↓ scroll • PgUp/PgDn page • Home/End • Esc close", width = "70%", maxHeight = "80%", visibleLines = 20, paddingX = 2, margin = 2, } = options; if (!ctx.hasUI) { return; } await ctx.ui.custom( (tui, theme, _keybindings, done) => { /* * The caller owns all content formatting. When content is a * function, it receives Pi's theme and returns final ANSI-formatted * terminal strings. */ const formattedContent = typeof content === "function" ? content(theme) : content; const lines = normalizeContent(formattedContent); return new ScrollableTextOverlay({ title, subtitle, lines, footer, visibleLines, paddingX, onClose: () => done(), onChange: () => tui.requestRender(), }); }, { overlay: true, overlayOptions: { anchor: "center", width, maxHeight, margin, }, }, ); }