/** * A reusable, dependency-light box-drawing toggle-menu component for Pi * extensions, built on top of `@earendil-works/pi-tui`'s `Component` * contract and `ctx.ui.custom()` overlay API. * * Renders a titled box containing one or more sections of boolean toggle, * multi-value cycle, or keyboard-reorderable items, plus an optional live-updating * preview section driven by a caller-supplied render callback. * * Intended to be reused by any extension that needs a simple modal toggle * menu; it has no dependency on this extension's own settings shape. */ import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent"; import { type Component, Key, matchesKey, truncateToWidth, type SizeValue, type TUI, visibleWidth } from "@earendil-works/pi-tui"; export type MenuValue = boolean | string; /** Lines to display and an optional delay before the preview should refresh. */ export interface PreviewResult { lines: string[]; nextRefreshInMs?: number; } export interface MenuItem { id: string; label: string; value: MenuValue; /** Values cycled with left/right arrows. Omit for a boolean space-toggle. */ cycleValues?: readonly string[]; /** Optional display labels for cycle values; the keys remain the published values. */ cycleValueLabels?: Readonly>; /** ID of the boolean value that gates cycling; space toggles it. */ cycleEnabledBy?: string; /** Initial enabled state when `cycleEnabledBy` is set (default true). */ cycleEnabled?: boolean; /** Value snapped to when the gating checkbox is unchecked. */ cycleDisabledValue?: string; /** * Marks the item as a reorderable row. Space grabs/releases it; while grabbed, * up/down move it among the other rows sharing this group instead of moving the * cursor. The group's current order is published as a comma-joined list of item * ids under this key in the result values. */ reorderGroup?: string; } export interface MenuSection { title: string; items: MenuItem[]; } export interface MenuConfig { title: string; sections: MenuSection[]; hints?: string[]; /** * Optional preview renderer, shown in its own "Preview" section above the * toggle sections. Called on every render with the menu's current (possibly * toggled but not-yet-applied) values, the milliseconds elapsed since the menu * opened, the id of the item under the cursor (undefined for an empty menu), * and the width available to each line. Lines may contain ANSI styling and * are truncated/padded to fit automatically. * * Omitting `nextRefreshInMs` (or returning a value <= 0) makes the preview static. */ preview?: (values: Record, elapsedMs: number, activeItemId: string | undefined, width: number) => PreviewResult; /** Optional heading for the preview block. Defaults to `Preview`. */ previewTitle?: string; /** Maximum overlay height in rows or as a percentage of the terminal height. */ maxHeight?: SizeValue; } export interface MenuResult { applied: boolean; values: T; } const DEFAULT_HINTS = ["\u2191\u2193 move", "PgUp/PgDn page", "\u2423 toggle", "\u23ce apply", "esc cancel"]; const OVERLAY_WIDTH = "86%"; export const DEFAULT_PREVIEW_WIDTH = 76; const ROW_PREFIX_WIDTH = 8; // " " + marker + " " + "[" + box + "]" + " " /** Close OSC 8 links truncated by pi-tui's SGR-only truncation reset. */ function closeTruncatedHyperlink(text: string): string { const lastOpen = text.lastIndexOf("\x1b]8;;"); const lastClose = text.lastIndexOf("\x1b]8;;\x1b\\"); return lastOpen > lastClose ? `${text}\x1b]8;;\x1b\\` : text; } interface FlatItem { item: MenuItem; sectionIndex: number; } function buildInitialValues(config: MenuConfig): Record { const values: Record = {}; const reorderGroups = new Map(); for (const section of config.sections) { for (const item of section.items) { values[item.id] = item.value; if (item.cycleEnabledBy) values[item.cycleEnabledBy] = item.cycleEnabled ?? true; if (item.reorderGroup) { const ids = reorderGroups.get(item.reorderGroup) ?? []; ids.push(item.id); reorderGroups.set(item.reorderGroup, ids); } } } for (const [group, ids] of reorderGroups) values[group] = ids.join(","); return values; } /** Internal Component implementing the box-drawing toggle menu. */ export class MenuComponent implements Component { private readonly theme: Theme; private readonly done: (result: MenuResult>) => void; private readonly title: string; private readonly sections: MenuSection[]; private readonly hints: string[]; private readonly previewTitle: string; private readonly initialValues: Record; private readonly values: Record; private readonly flat: FlatItem[]; private readonly previewFn: MenuConfig["preview"]; private readonly previewOrigin: number | undefined; private readonly tui: TUI | undefined; private readonly maxHeight: SizeValue | undefined; private previewTimer: ReturnType | undefined; private previewNextRefreshMs: number | undefined; private disposed = false; private cursor = 0; private scrollStart = 0; private cachedWidth: number | undefined; private cachedRows: number | undefined; private cachedLines: string[] | undefined; private pageItemCount: number | undefined; constructor( config: MenuConfig, theme: Theme, done: (result: MenuResult>) => void, tui?: TUI, ) { this.theme = theme; this.done = done; this.title = config.title; this.sections = config.sections; this.hints = config.hints ?? DEFAULT_HINTS; this.previewTitle = config.previewTitle ?? "Preview"; this.tui = tui; this.maxHeight = config.maxHeight; this.values = buildInitialValues(config); this.flat = []; for (const [sectionIndex, section] of config.sections.entries()) { for (const item of section.items) { this.flat.push({ item, sectionIndex }); } } this.initialValues = { ...this.values }; this.previewFn = config.preview; if (this.previewFn) { this.previewOrigin = Date.now(); if (tui) { this.samplePreview(); this.schedulePreview(); } } } /** Stops the preview animation timer, if any. Called automatically when the overlay closes. */ dispose(): void { this.disposed = true; if (this.previewTimer !== undefined) { clearTimeout(this.previewTimer); this.previewTimer = undefined; } } handleInput(data: string): void { if (this.flat.length === 0) { if (matchesKey(data, Key.enter)) { this.done({ applied: true, values: { ...this.values } }); } else if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) { this.done({ applied: false, values: { ...this.initialValues } }); } return; } // Map input to a normalized key name. let mappedKey: string | undefined; for (const k of [Key.up, Key.down, Key.pageUp, Key.pageDown, Key.left, Key.right, Key.space, Key.enter, Key.escape]) { if (matchesKey(data, k)) { mappedKey = k; break; } } if (!mappedKey && matchesKey(data, Key.ctrl("c"))) mappedKey = Key.escape; const keyActions: Record void> = { [Key.up]: () => { if (this.moveGrabbedItem(-1)) return; this.cursor = Math.max(0, this.cursor - 1); this.invalidate(); }, [Key.down]: () => { if (this.moveGrabbedItem(1)) return; this.cursor = Math.min(this.flat.length - 1, this.cursor + 1); this.invalidate(); }, [Key.pageUp]: () => this.moveByPage(-1), [Key.pageDown]: () => this.moveByPage(1), [Key.left]: () => this.cycleCurrentValue(-1), [Key.right]: () => this.cycleCurrentValue(1), [Key.space]: () => { const item = this.flat[this.cursor]!.item; if (item.cycleValues && item.cycleEnabledBy) { this.values[item.cycleEnabledBy] = !this.values[item.cycleEnabledBy] as boolean; if (!this.values[item.cycleEnabledBy] && item.cycleDisabledValue !== undefined) this.values[item.id] = item.cycleDisabledValue; } else if (!item.cycleValues) this.values[item.id] = !this.values[item.id] as boolean; this.invalidate(); }, [Key.enter]: () => this.done({ applied: true, values: { ...this.values } }), [Key.escape]: () => this.done({ applied: false, values: { ...this.initialValues } }), }; const handler = mappedKey ? keyActions[mappedKey] : undefined; if (handler) handler(); } invalidate(): void { this.cachedWidth = undefined; this.cachedRows = undefined; this.cachedLines = undefined; } render(width: number): string[] { const rows = this.availableRows(); if (this.cachedWidth === width && this.cachedRows === rows && this.cachedLines) return this.cachedLines; const lines = this.buildLines(width, rows); this.cachedWidth = width; this.cachedRows = rows; this.cachedLines = lines; this.schedulePreview(); return lines; } /** * Move the grabbed reorder row within its group, clamped at the group edges. * Returns true when the key belongs to the mover and must not also move the * cursor — which is what keeps a grab from escaping its group. */ private moveGrabbedItem(delta: number): boolean { const grabbed = this.flat[this.cursor]!; const group = grabbed.item.reorderGroup; if (group === undefined || this.values[grabbed.item.id] !== true) return false; let start = this.cursor; while (start > 0 && this.flat[start - 1]!.item.reorderGroup === group) start--; let end = this.cursor; while (end < this.flat.length - 1 && this.flat[end + 1]!.item.reorderGroup === group) end++; const target = Math.max(start, Math.min(end, this.cursor + delta)); if (target !== this.cursor) { this.flat.splice(this.cursor, 1); this.flat.splice(target, 0, grabbed); this.cursor = target; this.values[group] = this.flat.slice(start, end + 1).map(flat => flat.item.id).join(","); this.invalidate(); } return true; } private moveByPage(direction: -1 | 1): void { const pageItemCount = this.getPageItemCount(); if (this.moveGrabbedItem(direction * pageItemCount)) return; this.cursor = Math.max(0, Math.min(this.flat.length - 1, this.cursor + direction * pageItemCount)); this.invalidate(); } private getPageItemCount(): number { if (this.pageItemCount !== undefined) return this.pageItemCount; const rows = this.availableRows(); if (rows === undefined) return Math.max(1, Math.min(this.flat.length, 10)); return Math.max(1, rows - 6); } private cycleCurrentValue(delta: number): void { const item = this.flat[this.cursor]!.item; if (!item.cycleValues?.length || (item.cycleEnabledBy && !this.values[item.cycleEnabledBy])) return; const current = item.cycleValues.indexOf(this.values[item.id] as string); const index = (current + delta + item.cycleValues.length) % item.cycleValues.length; this.values[item.id] = item.cycleValues[index]!; this.invalidate(); } private samplePreview(width: number = DEFAULT_PREVIEW_WIDTH): string[] | undefined { if (!this.previewFn) return undefined; const result = this.previewFn( this.values, this.previewOrigin !== undefined ? Date.now() - this.previewOrigin : 0, this.flat[this.cursor]?.item.id, width, ); this.previewNextRefreshMs = result.nextRefreshInMs; return result.lines; } private schedulePreview(): void { if (this.previewTimer !== undefined) { clearTimeout(this.previewTimer); this.previewTimer = undefined; } const nextRefreshInMs = this.previewNextRefreshMs; if (this.disposed || !this.tui || nextRefreshInMs === undefined || nextRefreshInMs <= 0) return; this.previewTimer = setTimeout(() => { this.invalidate(); this.tui?.requestRender(); }, nextRefreshInMs); } private buildPreviewBlock(previewLines: string[] | undefined, innerWidth: number): string[] { if (!previewLines?.length) return []; return [this.renderSectionDivider(this.previewTitle, innerWidth), this.renderBlankRow(innerWidth), ...previewLines.map(line => this.renderContentRow(` ${line}`, innerWidth)), this.renderBlankRow(innerWidth)]; } /** Render every section from `this.flat`, the single source of truth for row order. */ private buildToggleSections(innerWidth: number): string[] { const lines: string[] = []; let currentSection = -1; for (const [index, flat] of this.flat.entries()) { if (flat.sectionIndex !== currentSection) { if (currentSection !== -1) lines.push(this.renderBlankRow(innerWidth)); lines.push(this.renderSectionDivider(this.sections[flat.sectionIndex]!.title, innerWidth)); currentSection = flat.sectionIndex; } lines.push(this.renderItemRow(flat.item, index === this.cursor, innerWidth)); } if (lines.length) lines.push(this.renderBlankRow(innerWidth)); return lines; } /** Build a section-aware item window, keeping a divider above each visible section. */ private buildToggleWindow(innerWidth: number, start: number, maxRows: number): { lines: string[]; end: number } { const lines: string[] = []; let currentSection = -1; let end = start - 1; for (let index = start; index < this.flat.length && lines.length < maxRows; index++) { const flat = this.flat[index]!; if (flat.sectionIndex !== currentSection) { const remaining = maxRows - lines.length; // A section continued from above needs room for a blank separator // row, its divider, and at least one item row; otherwise end the // window here rather than crowd the divider against the previous // section or fold the section into it. The window's first section // always renders (a degenerate window may drop its divider). if (lines.length > 0 && remaining < 3) break; if (lines.length > 0 && remaining >= 3) lines.push(this.renderBlankRow(innerWidth)); if (maxRows - lines.length >= 2) lines.push(this.renderSectionDivider(this.sections[flat.sectionIndex]!.title, innerWidth)); currentSection = flat.sectionIndex; } if (lines.length >= maxRows) break; lines.push(this.renderItemRow(flat.item, index === this.cursor, innerWidth)); end = index; } if (end === this.flat.length - 1 && lines.length < maxRows) lines.push(this.renderBlankRow(innerWidth)); return { lines, end }; } /** Render the scrolling settings body while keeping the selected item in view. */ private buildResponsiveToggleSections(innerWidth: number, maxRows: number): string[] { if (maxRows <= 0) { this.pageItemCount = 1; return []; } if (maxRows === 1) { this.pageItemCount = 1; return this.buildToggleWindow(innerWidth, this.cursor, 1).lines; } const contentRows = maxRows - 1; // Reserve one fixed row for scroll status. if (this.cursor < this.scrollStart) this.scrollStart = this.cursor; this.scrollStart = Math.max(0, Math.min(this.scrollStart, this.flat.length - 1)); // Walk backward from cursor to find the lowest scrollStart that fits in contentRows. { let rowsUsed = 2; // 1 for cursor item + 1 for first-section divider let section = this.flat[this.cursor]!.sectionIndex; this.scrollStart = this.cursor; for (let i = this.cursor - 1; i >= 0; i--) { const sec = this.flat[i]!.sectionIndex; if (sec !== section) { if (rowsUsed + 3 > contentRows) break; rowsUsed += 3; section = sec; } else { if (rowsUsed + 1 > contentRows) break; rowsUsed += 1; } this.scrollStart = i; } } const window = this.buildToggleWindow(innerWidth, this.scrollStart, contentRows); this.pageItemCount = Math.max(1, window.end - this.scrollStart + 1); const rows = [...window.lines]; while (rows.length < contentRows) rows.push(this.renderBlankRow(innerWidth)); rows.push(this.renderScrollStatus(this.scrollStart > 0, window.end < this.flat.length - 1, innerWidth)); return rows; } private buildFooter(innerWidth: number): string[] { return [this.renderSeparator(innerWidth), this.renderHintsRow(innerWidth), this.renderBottomBorder(innerWidth)]; } private availableRows(): number | undefined { const rows = this.tui?.terminal?.rows; if (typeof rows !== "number" || !Number.isFinite(rows) || rows <= 0) return undefined; const terminalRows = Math.floor(rows); if (this.maxHeight === undefined) return terminalRows; const requestedRows = typeof this.maxHeight === "number" ? this.maxHeight : (Number.parseFloat(this.maxHeight) / 100) * terminalRows; if (!Number.isFinite(requestedRows)) return terminalRows; return Math.max(1, Math.min(terminalRows, Math.floor(requestedRows))); } private buildLines(maxWidth: number, maxRows?: number): string[] { const boxWidth = Math.max(0, maxWidth); const innerWidth = Math.max(0, boxWidth - 2); // Preview rows are prefixed with a leading space by buildPreviewBlock/renderContentRow. const previewLines = this.samplePreview(Math.max(0, innerWidth - 1)); const header = [this.renderTopBorder(innerWidth), ...this.buildPreviewBlock(previewLines, innerWidth)]; const footer = this.buildFooter(innerWidth); const sectionsWithItems = new Set(this.flat.map(f => f.sectionIndex)).size; const naturalBodyLength = this.flat.length + 2 * sectionsWithItems; if (maxRows === undefined || header.length + naturalBodyLength + footer.length <= maxRows) { const naturalBody = this.buildToggleSections(innerWidth); this.pageItemCount = Math.max(1, this.flat.length); return [...header, ...naturalBody, ...footer].map(line => truncateToWidth(line, boxWidth, "")); } const bodyRows = Math.max(0, maxRows - header.length - footer.length); const body = this.buildResponsiveToggleSections(innerWidth, bodyRows); return [...header, ...body, ...footer].slice(0, maxRows).map(line => truncateToWidth(line, boxWidth, "")); } private wrap(left: string, content: string, right: string): string { const th = this.theme; return th.fg("border", left) + content + th.fg("border", right); } /** Render a filled horizontal line with optional title. */ private renderFilledLine( left: string, right: string, fillChar: string, innerWidth: number, title?: string, titlePrefix?: string, titleSuffix?: string, boldTitle?: boolean, ): string { const th = this.theme; if (!title) { const content = th.fg("border", fillChar.repeat(innerWidth)); return this.wrap(left, content, right); } const prefix = titlePrefix ?? ""; const suffix = titleSuffix ?? ""; const maxTitleLen = Math.max(0, innerWidth - prefix.length - suffix.length); const shownTitle = visibleWidth(title) > maxTitleLen ? truncateToWidth(title, maxTitleLen) : title; const styledTitle = boldTitle ? th.bold(shownTitle) : shownTitle; const fillCount = Math.max(0, innerWidth - visibleWidth(prefix + shownTitle + suffix)); const titleContent = th.fg("text", styledTitle); const content = `${th.fg("border", prefix)}${titleContent}${th.fg("border", suffix + fillChar.repeat(fillCount))}`; return this.wrap(left, content, right); } private renderTopBorder(innerWidth: number): string { return this.renderFilledLine("\u2554", "\u2557", "\u2550", innerWidth, this.title, "\u2550[ ", " ]", true); } private renderBottomBorder(innerWidth: number): string { const counter = `[ ${this.cursor + 1}/${this.flat.length} ]`; const fillCount = Math.max(0, innerWidth - counter.length); const content = `${"\u2550".repeat(fillCount)}${counter}`; return this.wrap("\u255a", this.theme.fg("border", content), "\u255d"); } private renderSectionDivider(title: string, innerWidth: number): string { return this.renderFilledLine("\u255f", "\u2562", "\u2500", innerWidth, title, "\u2500 ", " \u2500"); } private renderSeparator(innerWidth: number): string { return this.renderFilledLine("\u255f", "\u2562", "\u2500", innerWidth); } private renderBlankRow(innerWidth: number): string { return this.wrap("\u2551", " ".repeat(innerWidth), "\u2551"); } /** Pads/truncates an already-styled content string to exactly `innerWidth` and wraps it in border chars. */ private renderContentRow(content: string, innerWidth: number): string { const truncated = visibleWidth(content) > innerWidth; const shown = truncated ? closeTruncatedHyperlink(truncateToWidth(content, innerWidth)) : content; const pad = Math.max(0, innerWidth - visibleWidth(shown)); return this.wrap("\u2551", `${shown}${" ".repeat(pad)}`, "\u2551"); } private renderHintsRow(innerWidth: number): string { const plain = ` ${this.hints.join(" ")}`; return this.renderContentRow(this.theme.fg("dim", plain), innerWidth); } private renderScrollStatus(hasAbove: boolean, hasBelow: boolean, innerWidth: number): string { const parts = [hasAbove ? "↑ more above" : "", hasBelow ? "↓ more below" : ""].filter(Boolean); return this.renderContentRow(this.theme.fg("dim", ` ${parts.join(" ")}`), innerWidth); } private renderItemRow(item: MenuItem, selected: boolean, innerWidth: number): string { const th = this.theme; const value = this.values[item.id]!; const marker = selected ? "\u276f" : " "; const markerColored = selected ? th.fg("accent", marker) : marker; if (item.reorderGroup) { const held = value as boolean; const stateWord = held ? "\u2191 \u2193" : ""; const rightPlain = `${stateWord} `; const maxLabelLen = Math.max(0, innerWidth - ROW_PREFIX_WIDTH - visibleWidth(rightPlain) - 1); const label = visibleWidth(item.label) > maxLabelLen ? truncateToWidth(item.label, maxLabelLen) : item.label; const leftPlain = ` ${marker} [${held ? "\u25a0" : " "}] ${label}`; const gap = Math.max(1, innerWidth - visibleWidth(leftPlain) - visibleWidth(rightPlain)); const content = ` ${markerColored} [${held ? th.fg("accent", "\u25a0") : th.fg("muted", " ")}] ${th.fg("text", label)}${" ".repeat(gap)}${held ? th.fg("accent", stateWord) : ""} `; return this.wrap("\u2551", selected ? th.bg("selectedBg", content) : content, "\u2551"); } if (item.cycleValues) { const enabled = item.cycleEnabledBy ? this.values[item.cycleEnabledBy] as boolean : true; const rawValue = typeof value === "string" ? value : String(value); const displayValue = item.cycleValueLabels?.[rawValue] ?? rawValue; const stateWord = `‹ ${displayValue} ›`; const maxLabelLen = Math.max(0, innerWidth - ROW_PREFIX_WIDTH - visibleWidth(stateWord) - 1); const label = visibleWidth(item.label) > maxLabelLen ? truncateToWidth(item.label, maxLabelLen) : item.label; const leftPlain = ` ${marker} [${enabled ? "■" : " "}] ${label}`; const gap = Math.max(1, innerWidth - visibleWidth(leftPlain) - visibleWidth(stateWord) - 2); const content = ` ${markerColored} [${enabled ? th.fg("success", "■") : th.fg("muted", " ")}] ${th.fg("text", label)}${" ".repeat(gap)}${enabled ? th.fg("accent", stateWord) : th.fg("muted", stateWord)} `; return this.wrap("\u2551", selected ? th.bg("selectedBg", content) : content, "\u2551"); } const enabled = value as boolean; const box = enabled ? "\u25a0" : " "; const stateWord = enabled ? "ON" : "OFF"; const rightPlain = `${stateWord} `; const maxLabelLen = Math.max(0, innerWidth - ROW_PREFIX_WIDTH - visibleWidth(rightPlain) - 1); const label = visibleWidth(item.label) > maxLabelLen ? truncateToWidth(item.label, maxLabelLen) : item.label; const leftPlain = ` ${marker} [${box}] ${label}`; const gap = Math.max(1, innerWidth - visibleWidth(leftPlain) - visibleWidth(rightPlain)); const content = ` ${markerColored} [${enabled ? th.fg("success", box) : th.fg("muted", box)}] ${th.fg("text", label)}${" ".repeat(gap)}${enabled ? th.fg("success", stateWord) : th.fg("muted", stateWord)} `; return this.wrap("\u2551", selected ? th.bg("selectedBg", content) : content, "\u2551"); } } /** * Show a modal box-drawing toggle menu and resolve once the user applies * (Enter) or cancels (Escape / Ctrl+C) it. * * Requires TUI mode; in any other mode this resolves immediately with * `applied: false` and the menu's initial values, doing nothing visible. */ export async function showMenu>( ctx: ExtensionCommandContext, config: MenuConfig, ): Promise> { const initialValues = buildInitialValues(config) as T; if (ctx.mode !== "tui") { return { applied: false, values: initialValues }; } const maxHeight = config.maxHeight ?? "100%"; return ctx.ui.custom>( (tui, theme, _keybindings, done) => new MenuComponent(config, theme, done as (result: MenuResult>) => void, tui), { overlay: true, overlayOptions: { width: OVERLAY_WIDTH, maxHeight } }, ); }