UNPKG

@workflow-manager/runner

Version:

CLI runner for in-memory and markdown workflow orchestration using ATEP-like envelopes

103 lines (102 loc) 3.49 kB
// Escape-code constants and ANSI-aware string math for the TUI renderer. // Pure string helpers only: no I/O, no terminal capability detection. // // Width is counted per UTF-16 code unit after stripping ANSI CSI sequences. // Grapheme clusters and East Asian wide characters are intentionally not // handled (no grapheme lib); this is acceptable for the status/log text the // TUI renders and keeps these helpers dependency-free. export const ALT_SCREEN_ENTER = "\x1b[?1049h"; export const ALT_SCREEN_LEAVE = "\x1b[?1049l"; export const CURSOR_HIDE = "\x1b[?25l"; export const CURSOR_SHOW = "\x1b[?25h"; export const CURSOR_HOME = "\x1b[H"; export const CLEAR_SCREEN = "\x1b[2J"; export const CLEAR_LINE_END = "\x1b[K"; const ESC = "\x1b"; const STYLE_RESET = "\x1b[0m"; /** * Returns the length of the ANSI CSI escape sequence starting at `start`, * or 0 when `start` does not begin a well-formed CSI sequence. * * CSI grammar: ESC `[` then parameter/intermediate bytes (0x20-0x3f) * terminated by a final byte (0x40-0x7e). */ function escapeSequenceLength(text, start) { if (text[start] !== ESC || text[start + 1] !== "[") { return 0; } let index = start + 2; while (index < text.length) { const code = text.charCodeAt(index); if (code >= 0x40 && code <= 0x7e) { return index - start + 1; } if (code < 0x20 || code > 0x3f) { return 0; } index += 1; } return 0; } export function stripAnsi(text) { let out = ""; let index = 0; while (index < text.length) { const skip = escapeSequenceLength(text, index); if (skip > 0) { index += skip; continue; } out += text[index]; index += 1; } return out; } /** ANSI-aware column count (per code unit; see module comment). */ export function visibleWidth(text) { return stripAnsi(text).length; } /** * Truncates `text` to at most `width` visible columns, appending `ellipsis` * when truncation happens. ANSI escape sequences are copied through intact * (never split) and do not count toward the width. If any escape sequence * was emitted before the cut, a style reset is appended so styling does not * bleed past the truncated text. * * Invariant: visibleWidth(result) <= width. When `width` is smaller than the * ellipsis itself, a slice of the ellipsis is returned to keep the invariant. */ export function truncateVisible(text, width, ellipsis = "…") { if (visibleWidth(text) <= width) { return text; } if (width <= 0) { return ""; } if (width < ellipsis.length) { return ellipsis.slice(0, width); } const budget = width - ellipsis.length; let out = ""; let visible = 0; let sawEscape = false; let index = 0; while (index < text.length && visible < budget) { const skip = escapeSequenceLength(text, index); if (skip > 0) { out += text.slice(index, index + skip); sawEscape = true; index += skip; continue; } out += text[index]; visible += 1; index += 1; } return out + ellipsis + (sawEscape ? STYLE_RESET : ""); } /** Right-pads `text` with spaces up to `width` visible columns (no-op when already wider). */ export function padVisible(text, width) { const missing = width - visibleWidth(text); return missing > 0 ? text + " ".repeat(missing) : text; }