/** * KHAELOR * File: src/tui/renderer/ansi.ts * Description: ANSI escape primitives and pure string-measurement helpers (strip, width, truncate, wrap). * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ export const ESC = "\x1b"; export const CSI = "\x1b["; /** DEC 2026 synchronized-output frame markers (TUI_DESIGN ยง1.3). */ export const SYNC_ON = "\x1b[?2026h"; export const SYNC_OFF = "\x1b[?2026l"; export const HIDE_CURSOR = "\x1b[?25l"; export const SHOW_CURSOR = "\x1b[?25h"; export const BRACKETED_PASTE_ON = "\x1b[?2004h"; export const BRACKETED_PASTE_OFF = "\x1b[?2004l"; export const ERASE_LINE = "\x1b[2K"; export const ERASE_DOWN = "\x1b[0J"; export const SGR_RESET = "\x1b[0m"; /** Matches CSI sequences, OSC sequences (BEL or ST terminated), and lone two-byte escapes. */ const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b./g; export function stripAnsi(s: string): string { return s.replace(ANSI_RE, ""); } /** * Visible width in terminal columns. Code-point counting: correct for the * ASCII/Latin/box-drawing/symbol repertoire KHAELOR emits; East-Asian * double-width content may over-run by design in V1 (documented limitation). */ export function visibleWidth(s: string): number { return [...stripAnsi(s)].length; } /** * Truncate a (possibly styled) string to `width` visible columns. * Escape sequences are preserved; if any styling was emitted before the cut, * a reset is appended so truncation never leaks styles into following text. */ export function truncateAnsi(s: string, width: number): string { if (width <= 0) return ""; if (visibleWidth(s) <= width) return s; let out = ""; let visible = 0; let sawEscape = false; let i = 0; while (i < s.length && visible < width) { if (s[i] === ESC) { ANSI_RE.lastIndex = i; const m = ANSI_RE.exec(s); if (m && m.index === i) { out += m[0]; sawEscape = true; i += m[0].length; continue; } } const cp = s.codePointAt(i); const ch = cp !== undefined ? String.fromCodePoint(cp) : (s[i] as string); out += ch; visible += 1; i += ch.length; } return sawEscape ? out + SGR_RESET : out; } /** Greedy word wrap for plain (unstyled) text; hard-splits words longer than the width. */ export function wrapText(text: string, width: number): string[] { const w = Math.max(1, width); const out: string[] = []; for (const raw of text.split("\n")) { if ([...raw].length <= w) { out.push(raw); continue; } let line = ""; for (const word of raw.split(" ")) { const candidate = line === "" ? word : line + " " + word; if ([...candidate].length <= w) { line = candidate; } else { if (line !== "") out.push(line); let rest = word; while ([...rest].length > w) { out.push([...rest].slice(0, w).join("")); rest = [...rest].slice(w).join(""); } line = rest; } } out.push(line); } return out; } /** Pad a (possibly styled) string with spaces to `width` visible columns. */ export function padEndAnsi(s: string, width: number): string { const w = visibleWidth(s); return w >= width ? s : s + " ".repeat(width - w); }