import type { Theme } from "@earendil-works/pi-coding-agent"; import { Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; import type { Component } from "@earendil-works/pi-tui"; const DEFAULT_DIALOG_ROWS = Math.max(12, Math.min(30, (process.stdout.rows ?? 30) - 8)); function repeat(char: string, count: number): string { return count > 0 ? char.repeat(count) : ""; } function fitLine(content: string, width: number): string { const trimmed = truncateToWidth(content, width, "…"); const pad = Math.max(0, width - visibleWidth(trimmed)); return trimmed + repeat(" ", pad); } /** * Renders the help line: `{key}` markers become bold accent-colored so the * interactive keys stand out; everything else stays dim. */ function renderHelp(theme: Theme, helpText: string): string { return helpText .split(/(\{[^}]+\})/g) .map((part) => { if (part.startsWith("{") && part.endsWith("}")) { return theme.fg("accent", theme.bold(part.slice(1, -1))); } return theme.fg("dim", part); }) .join(""); } interface SplitDialogOptions { title: string; /** Called at render time; takes precedence over `title` when set. */ getTitle?: () => string; helpText?: string; width?: number; /** Total dialog height budget (borders + header + body + help). */ maxRows?: number; /** Fixed area below the title; never scrolls. */ renderHeader: (innerWidth: number) => string[]; /** Scrollable area below the header. */ renderBody: (innerWidth: number) => string[]; /** * Called before default scroll/close handling. * Return `true` if the key was consumed — the dialog invalidates and re-renders. */ onKey?: (data: string) => boolean; } /** * Editor-slot dialog with a fixed header and a scrollable body. * The header is rendered exactly once per frame, so session-wide summary * data is never duplicated across views. */ export class SplitDialog implements Component { private scrollOffset = 0; private cachedWidth?: number; private cachedLines?: string[]; constructor( private readonly theme: Theme, private readonly options: SplitDialogOptions, private readonly onClose: () => void, ) {} private bodyRows(headerHeight: number): number { const maxRows = this.options.maxRows ?? DEFAULT_DIALOG_ROWS; // Chrome: top border + title + blank + header + blank + body + blank + help + bottom border = 7 + headerHeight return Math.max(1, maxRows - headerHeight - 7); } handleInput(data: string): void { const pageSize = Math.max(1, this.bodyRows(3) - 2); if (this.options.onKey) { const handled = this.options.onKey(data); if (handled) { this.invalidate(); return; } } // Vim-style scrolling: j = down, k = up (arrows still work). if (data === "j") { this.scrollOffset += 1; this.invalidate(); return; } if (data === "k") { this.scrollOffset = Math.max(0, this.scrollOffset - 1); this.invalidate(); return; } if (data === "q") { this.onClose(); return; } if (matchesKey(data, Key.up)) { this.scrollOffset = Math.max(0, this.scrollOffset - 1); this.invalidate(); return; } if (matchesKey(data, Key.down)) { this.scrollOffset += 1; this.invalidate(); return; } if (matchesKey(data, Key.pageUp)) { this.scrollOffset = Math.max(0, this.scrollOffset - pageSize); this.invalidate(); return; } if (matchesKey(data, Key.pageDown)) { this.scrollOffset += pageSize; this.invalidate(); return; } if (matchesKey(data, Key.home)) { this.scrollOffset = 0; this.invalidate(); return; } if (matchesKey(data, Key.end)) { this.scrollOffset = Number.MAX_SAFE_INTEGER; this.invalidate(); } } render(width: number): string[] { if (this.cachedLines && this.cachedWidth === width) return this.cachedLines; const dialogWidth = Math.max(40, Math.min(this.options.width ?? width, width)); const innerWidth = dialogWidth - 2; const headerLines = this.options.renderHeader(Math.max(10, innerWidth)); const bodyLines = this.options.renderBody(Math.max(10, innerWidth)); const bodyRows = this.bodyRows(headerLines.length); const maxScrollOffset = Math.max(0, bodyLines.length - bodyRows); const scrollOffset = Math.min(this.scrollOffset, maxScrollOffset); this.scrollOffset = scrollOffset; const visibleBodyLines = bodyLines.slice(scrollOffset, scrollOffset + bodyRows); const lines: string[] = []; const topBorder = this.theme.fg("borderAccent", `╭${repeat("─", innerWidth)}╮`); const bottomBorder = this.theme.fg("borderAccent", `╰${repeat("─", innerWidth)}╯`); const border = this.theme.fg("border", "│"); const resolvedTitle = this.options.getTitle ? this.options.getTitle() : this.options.title; const title = this.theme.fg("accent", this.theme.bold(` ${resolvedTitle} `)); lines.push(topBorder); lines.push(`${border}${fitLine(title, innerWidth)}${border}`); lines.push(`${border}${fitLine("", innerWidth)}${border}`); for (const line of headerLines) { lines.push(`${border}${fitLine(line, innerWidth)}${border}`); } lines.push(`${border}${fitLine("", innerWidth)}${border}`); for (const line of visibleBodyLines) { lines.push(`${border}${fitLine(line, innerWidth)}${border}`); } for (let i = visibleBodyLines.length; i < bodyRows; i += 1) { lines.push(`${border}${fitLine("", innerWidth)}${border}`); } lines.push(`${border}${fitLine("", innerWidth)}${border}`); const helpText = this.options.helpText ?? "{q} close"; lines.push(`${border}${fitLine(renderHelp(this.theme, helpText), innerWidth)}${border}`); lines.push(bottomBorder); this.cachedWidth = width; this.cachedLines = lines; return lines; } invalidate(): void { this.cachedWidth = undefined; this.cachedLines = undefined; } }