import { CURSOR_MARKER, Editor, type EditorOptions, type EditorTheme, Key, type KeybindingsManager, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, } from "@earendil-works/pi-tui"; import type { AskAnswer, AskOption } from "./domain.ts"; export function sanitizeDisplayText(text: string): string { return text .replace(/\r\n?/g, "\n") .replace(/\x1B\][^\x07\x1B]*(?:\x07|\x1B\\)/g, "") .replace(/\x1B_[\s\S]*?\x1B\\/g, "") .replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, "") .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, " "); } export function sanitizeEditorDisplay(text: string): string { // SafeEditor removes untrusted markers before Editor creates this output, so // any marker here has provenance as the focused editor's IME cursor. const markerIndex = text.lastIndexOf(CURSOR_MARKER); if (markerIndex < 0) return sanitizeDisplayText(text); const before = text.slice(0, markerIndex); const after = text.slice(markerIndex + CURSOR_MARKER.length); return `${sanitizeDisplayText(before)}${CURSOR_MARKER}${sanitizeDisplayText(after)}`; } export function renderOptionalNote( lines: string[], width: number, theme: any, noteEditor: Editor, noteFocused: boolean, indent: number, ): void { const safeIndent = Math.max(2, Math.min(indent, Math.max(2, width - 1))); const labelPrefix = " ".repeat(Math.max(0, safeIndent - 2)); const note = sanitizeDisplayText(noteEditor.getText()); if (!noteFocused && !note) { lines.push(truncateToWidth(`${labelPrefix}${theme.fg("muted", "+ Add note (optional)")}${theme.fg("dim", " · Tab")}`, width)); return; } let labelText = "Note: "; if (noteFocused) { // Keep enough editor columns for typed text plus its fake cursor and // zero-width hardware cursor marker. Prefer the descriptive label, then // compact it before falling back to a separate label row. const minimumEditorWidth = 6; const labelVariants = ["Note (optional): ", "Note: ", "N: "]; const inlineLabel = labelVariants.find( (candidate) => visibleWidth(labelPrefix) + visibleWidth(candidate) + minimumEditorWidth <= width, ); if (!inlineLabel) { lines.push(truncateToWidth(`${labelPrefix}${theme.fg("accent", theme.bold("N:"))}`, width)); for (const editorLine of noteEditor.render(Math.max(1, width)).slice(1, -1)) { lines.push(truncateToWidth(sanitizeEditorDisplay(editorLine), width)); } return; } labelText = inlineLabel; } const label = `${labelPrefix}${theme.fg(noteFocused ? "accent" : "muted", noteFocused ? theme.bold(labelText) : labelText)}`; const contentPrefix = " ".repeat(visibleWidth(label)); if (noteFocused) { const editorWidth = width - visibleWidth(label); const editorLines = noteEditor.render(editorWidth).slice(1, -1); lines.push(truncateToWidth(`${label}${sanitizeEditorDisplay(editorLines[0] ?? "")}`, width)); for (const editorLine of editorLines.slice(1)) { lines.push(truncateToWidth(`${contentPrefix}${sanitizeEditorDisplay(editorLine)}`, width)); } return; } const logicalLines = note.split("\n"); for (let index = 0; index < logicalLines.length; index++) { addWrappedWithPrefix( lines, index === 0 ? label : contentPrefix, theme.fg("dim", logicalLines[index] || " "), width, contentPrefix, ); } } function scrubCursorMarkers(text: string): string { return text.replaceAll(CURSOR_MARKER, ""); } /** Keep marker provenance at the shared editable-input boundary. */ export class SafeEditor extends Editor { override setText(text: string): void { super.setText(scrubCursorMarkers(text)); } override insertTextAtCursor(text: string): void { super.insertTextAtCursor(scrubCursorMarkers(text)); } override handleInput(data: string): void { super.handleInput(scrubCursorMarkers(data)); } } /** Build the complete editor palette from the active question UI theme. */ export function questionEditorTheme(theme: any): EditorTheme { return { borderColor: (text) => theme.fg("accent", text), selectList: { selectedPrefix: (text) => theme.fg("accent", text), selectedText: (text) => theme.fg("accent", text), description: (text) => theme.fg("muted", text), scrollInfo: (text) => theme.fg("dim", text), noMatch: (text) => theme.fg("warning", text), }, }; } export function createQuestionEditor(tui: any, theme: any, options: EditorOptions = { paddingX: 0 }): SafeEditor { return new SafeEditor(tui, questionEditorTheme(theme), options); } export function createNoteEditor(tui: any, theme: any): Editor { const editor = createQuestionEditor(tui, theme); // Enter must never clear a note. Shift+Enter/Ctrl+J insert new lines; // Tab leaves the note and Ctrl/Alt+Enter submits the question form. editor.disableSubmit = true; return editor; } export function normalizeFocusCycleKey(data: string): string { return matchesKey(data, Key.shift("tab")) ? "\t" : data; } export function isSubmitEnter(data: string): boolean { return matchesKey(data, Key.enter) && !matchesKey(data, Key.ctrl("j")); } /** Ctrl+? with Ctrl+/ as an equivalent enhanced-terminal encoding. */ export function isAskAgentKey(data: string): boolean { return matchesKey(data, Key.ctrl("?")) || matchesKey(data, Key.ctrl("/")); } /** A multiline free-text question with an inline optional note. */ export function addWrappedWithPrefix( lines: string[], prefix: string, text: string, width: number, continuationPrefix = " ".repeat(visibleWidth(prefix)), ): void { const renderWidth = Math.max(1, width); const prefixWidth = visibleWidth(prefix); // Extremely narrow terminals cannot reserve the prefix separately. Wrapping // the combined content still honors the component's hard width contract. if (prefixWidth >= renderWidth) { for (const line of wrapTextWithAnsi(`${prefix}${text}`, renderWidth)) { lines.push(truncateToWidth(line, renderWidth)); } return; } const wrapped = wrapTextWithAnsi(text, renderWidth - prefixWidth); for (let i = 0; i < wrapped.length; i++) { const linePrefix = i === 0 ? prefix : continuationPrefix; lines.push(truncateToWidth(`${linePrefix}${wrapped[i]}`, renderWidth)); } } /** Render a label and its optional paragraph with the same hanging geometry. */ export function addWrappedOption( lines: string[], prefix: string, label: string, description: string | undefined, width: number, descriptionPrefix = " ".repeat(visibleWidth(prefix)), continuationPrefix = " ".repeat(visibleWidth(prefix)), ): void { addWrappedWithPrefix(lines, prefix, label, width, continuationPrefix); if (description) { addWrappedWithPrefix(lines, descriptionPrefix, description, width, descriptionPrefix); } } export type ChoiceListAction = "changed" | "confirm" | "cancel" | "unhandled"; export interface WrappedChoiceItem { /** Stable cursor identity; option values are allowed to collide. */ id: string; index: number; label: string; description?: string; option?: AskOption; isOther: boolean; } export interface ChoiceListRenderOptions { /** Presence of this map enables checkbox rendering, even when it is empty. */ selectedAnswers?: ReadonlyMap; /** Render the native multiline editor directly beneath the Other choice. */ inlineOtherEditor?: Editor; /** Single-select lists show an explicit radio independently of the cursor. */ showRadio?: boolean; /** Host-computed line budget after all surrounding chrome is reserved. */ availableLines?: number; } /** * Owns every coupled piece of option-list behavior: cursor movement, the * item-count window, and ANSI-aware multiline rendering. Keeping those rules * behind one interface prevents navigation and presentation from drifting. * * Answer persistence and the custom-answer Input deliberately remain with the * host question flow; they have different submit semantics in each mode. */ export class WrappedChoiceList { private readonly items: WrappedChoiceItem[]; private readonly maxVisible: number; private readonly keybindings: KeybindingsManager; private readonly theme: any; private selectedIndex = 0; constructor( options: AskOption[], otherLabel: string, maxVisible: number, keybindings: KeybindingsManager, theme: any, ) { this.keybindings = keybindings; this.theme = theme; this.items = options.map((option, index) => ({ id: `option:${index}`, index: index + 1, label: sanitizeDisplayText(option.label), description: option.description ? sanitizeDisplayText(option.description) : undefined, option, isOther: false, })); this.items.push({ id: "other", index: options.length + 1, label: otherLabel, description: "Write your own answer.", isOther: true, }); this.maxVisible = Math.max(1, Math.min(maxVisible, this.items.length)); } get length(): number { return this.items.length; } get selectedItem(): WrappedChoiceItem { return this.items[this.selectedIndex]; } setSelectedIndex(index: number): void { this.selectedIndex = Math.max(0, Math.min(index, this.items.length - 1)); } selectOther(): void { this.selectedIndex = this.items.findIndex((item) => item.isOther); } /** Apply only list-level bindings; hosts retain mode-specific key precedence. */ handleInput(data: string): ChoiceListAction { if (this.keybindings.matches(data, "tui.select.up")) { this.selectedIndex = this.selectedIndex === 0 ? this.items.length - 1 : this.selectedIndex - 1; return "changed"; } if (this.keybindings.matches(data, "tui.select.down")) { this.selectedIndex = this.selectedIndex === this.items.length - 1 ? 0 : this.selectedIndex + 1; return "changed"; } if (this.keybindings.matches(data, "tui.select.confirm")) return "confirm"; if (this.keybindings.matches(data, "tui.select.cancel")) return "cancel"; return "unhandled"; } render(width: number, options: ChoiceListRenderOptions = {}): string[] { const lines: string[] = []; const { selectedAnswers, inlineOtherEditor, showRadio = false, } = options; const showCheckboxes = selectedAnswers !== undefined && !showRadio; const startIndex = Math.max( 0, Math.min( this.selectedIndex - Math.floor(this.maxVisible / 2), this.items.length - this.maxVisible, ), ); const endIndex = Math.min(startIndex + this.maxVisible, this.items.length); for (let itemIndex = startIndex; itemIndex < endIndex; itemIndex++) { const item = this.items[itemIndex]; const hasCursor = itemIndex === this.selectedIndex; const answerKey = item.id; const checked = selectedAnswers?.has(answerKey) ?? false; const customActive = item.isOther && inlineOtherEditor !== undefined; const visiblySelected = checked || (customActive && !showRadio); // Radios communicate single-select state; checkboxes remain reserved // for actual multi-select semantics. const color = visiblySelected ? "success" : hasCursor ? "accent" : "text"; const cursorPrefix = hasCursor ? this.theme.fg(visiblySelected ? "success" : "accent", "→ ") : " "; const selectionPrefix = showCheckboxes ? `${visiblySelected ? "[x]" : "[ ]"} ` : showRadio ? `${visiblySelected ? "(●)" : "( )"} ` : ""; const prefix = `${cursorPrefix}${this.theme.fg(color, `${selectionPrefix}${item.index}. `)}`; let label = this.theme.fg(color, item.label); if (item.option?.recommended) label += this.theme.fg("success", " Recommended"); if (item.isOther && inlineOtherEditor) { addWrappedWithPrefix(lines, prefix, label, width); // Replace the normal description line with the native Editor so a // one-line answer leaves the list's geometry unchanged. At extreme // widths, reduce this secondary row's indent rather than hiding input. const editorIndent = " ".repeat(Math.min(visibleWidth(prefix), Math.max(0, width - 4))); const editorWidth = Math.max(1, width - visibleWidth(editorIndent)); const editorLines = inlineOtherEditor.render(editorWidth).slice(1, -1); for (const editorLine of editorLines) { lines.push(truncateToWidth(`${editorIndent}${sanitizeEditorDisplay(editorLine)}`, width)); } continue; } if (item.isOther) { const otherAnswer = selectedAnswers?.get("other"); if (otherAnswer?.type === "other") { label += this.theme.fg("muted", ` — ${sanitizeDisplayText(otherAnswer.label)}`); } } if (item.isOther) { addWrappedWithPrefix(lines, prefix, label, width); const descriptionPrefix = " ".repeat(Math.min(visibleWidth(prefix), Math.max(0, width - 4))); const descriptionWidth = Math.max(0, width - visibleWidth(descriptionPrefix)); lines.push(truncateToWidth( `${descriptionPrefix}${truncateToWidth(this.theme.fg("muted", item.description ?? ""), descriptionWidth)}`, width, )); continue; } const wrappedPrefix = " ".repeat(visibleWidth(prefix)); addWrappedOption( lines, prefix, label, item.description ? this.theme.fg("muted", item.description) : undefined, width, wrappedPrefix, wrappedPrefix, ); } if (startIndex > 0 || endIndex < this.items.length) { addWrappedWithPrefix(lines, " ", this.theme.fg("dim", `(${this.selectedIndex + 1}/${this.items.length})`), width); } const maxRenderedLines = options.availableLines === undefined ? Number.POSITIVE_INFINITY : Math.max(0, Math.floor(options.availableLines)); if (lines.length > maxRenderedLines) { if (maxRenderedLines === 0) return []; const editorCursorLine = lines.findIndex((line) => line.includes(CURSOR_MARKER)); const selectedLine = lines.findIndex((line) => line.includes("→")); const cursorLine = Math.max(0, editorCursorLine >= 0 ? editorCursorLine : selectedLine); const start = Math.max(0, Math.min(cursorLine - Math.floor(maxRenderedLines / 2), lines.length - maxRenderedLines)); const visible = lines.slice(start, start + maxRenderedLines); if (start > 0 && start !== cursorLine) { visible[0] = truncateToWidth(this.theme.fg("dim", " ↑ more"), width); } if (start + maxRenderedLines < lines.length && start + maxRenderedLines - 1 !== cursorLine) { visible[visible.length - 1] = truncateToWidth(this.theme.fg("dim", " ↓ more"), width); } return visible; } return lines; } } export function constrainFrameHeight(lines: string[], terminalRows: number | undefined, tailLines = 2): void { if (terminalRows === undefined || lines.length <= terminalRows) return; const keepTail = Math.min(Math.max(0, tailLines), terminalRows); const cursorLine = lines.findIndex((line) => line.includes(CURSOR_MARKER)); const bodyEnd = lines.length - keepTail; if (cursorLine >= 0 && cursorLine < bodyEnd) { const bodyBudget = terminalRows - keepTail; const start = Math.max(0, Math.min(cursorLine - bodyBudget + 1, bodyEnd - bodyBudget)); lines.splice(0, lines.length, ...lines.slice(start, start + bodyBudget), ...lines.slice(bodyEnd)); return; } const removeAt = Math.max(0, terminalRows - keepTail); lines.splice(removeAt, lines.length - terminalRows); } export function addWrapped(lines: string[], text: string, width: number, indent = ""): void { addWrappedWithPrefix(lines, indent, text, width, indent); } /** Keep model-provided progress labels compact as well as terminal-safe. */ export function sanitizeProgressLabel(text: string): string { return sanitizeDisplayText(text).replace(/[\t\n]/g, " "); } /** Truncate semantic labels by grapheme without pi-tui's three-dot suffix. */ export function truncateLabel(text: string, width: number): string { if (width <= 0) return ""; if (visibleWidth(text) <= width) return text; if (width === 1) return "…"; let result = ""; const Segmenter = (Intl as any).Segmenter; const graphemes: string[] = Segmenter ? Array.from(new Segmenter(undefined, { granularity: "grapheme" }).segment(text), (part: any) => part.segment) : Array.from(text); for (const grapheme of graphemes) { if (visibleWidth(result + grapheme) > width - 1) break; result += grapheme; } return `${result}…`; }