mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
131 lines
4.5 KiB
JavaScript
131 lines
4.5 KiB
JavaScript
/**
|
|
* OPS109 — helpers ANSI mínimos para o painel de issues (`pnpm issues:tui`).
|
|
*
|
|
* Puro by contract: só constantes de escape + operações de string (zero
|
|
* dependência, zero I/O). O painel roda num terminal SSH comum, então o
|
|
* conjunto é deliberadamente pequeno e conservador — cores funcionais de
|
|
* estado, negrito/dim e o controle de tela alternada. Nada de emoji ou
|
|
* glifos double-width: a largura é calculada por `visibleWidth`, que ignora
|
|
* as sequências CSI/SGR.
|
|
*/
|
|
|
|
const ESC = '\u001b['
|
|
const RESET = `${ESC}0m`
|
|
|
|
/** SGR codes used by the panel. Names map to the UI draft's functional roles. */
|
|
export const SGR = {
|
|
reset: RESET,
|
|
bold: `${ESC}1m`,
|
|
dim: `${ESC}2m`,
|
|
italic: `${ESC}3m`,
|
|
inverse: `${ESC}7m`,
|
|
red: `${ESC}31m`,
|
|
green: `${ESC}32m`,
|
|
yellow: `${ESC}33m`,
|
|
cyan: `${ESC}36m`,
|
|
gray: `${ESC}90m`,
|
|
}
|
|
|
|
/**
|
|
* Runs of CSI escape sequences: `ESC [ … final-byte`. Covers SGR
|
|
* (`\u001b[0m`) and cursor moves (`\u001b[2J`), so width math never counts
|
|
* control bytes. Deliberately not a full terminal parser (no OSC/links).
|
|
*/
|
|
const CSI_PATTERN = /\u001b\[[0-9;?]*[ -/]*[@-~]/g
|
|
|
|
/**
|
|
* Strip every CSI sequence — for measuring and for re-wrapping already
|
|
* styled text.
|
|
* @param {string} text
|
|
*/
|
|
export const stripAnsi = (text) => String(text ?? '').replace(CSI_PATTERN, '')
|
|
|
|
/**
|
|
* Printable width in terminal cells. Conservative: counts code points, not
|
|
* grapheme clusters or East-Asian double-width (the panel avoids those), so
|
|
* the value equals “how many characters the eye sees”.
|
|
* @param {string} text
|
|
*/
|
|
export const visibleWidth = (text) => [...stripAnsi(text)].length
|
|
|
|
/**
|
|
* Truncate to `width` visible columns, appending an ellipsis when it had to
|
|
* cut. The result is unstyled plain text (escape sequences are stripped):
|
|
* callers that need color apply it around the truncated value.
|
|
* @param {string} text
|
|
* @param {number} width
|
|
*/
|
|
export const truncateToWidth = (text, width) => {
|
|
const source = String(text ?? '')
|
|
const plain = stripAnsi(source)
|
|
const chars = [...plain]
|
|
const limit = Math.max(0, Math.trunc(width))
|
|
if (chars.length <= limit) return source
|
|
if (limit === 0) return ''
|
|
return `${chars.slice(0, Math.max(0, limit - 1)).join('')}…`
|
|
}
|
|
|
|
/**
|
|
* Pad a styled string with spaces up to `width` visible columns. When the
|
|
* text is longer it is truncated with `truncateToWidth`; padding happens
|
|
* AFTER the text so a background color covers the whole cell.
|
|
* @param {string} text
|
|
* @param {number} width
|
|
*/
|
|
export const padToWidth = (text, width) => {
|
|
const target = Math.max(0, Math.trunc(width))
|
|
const clipped = visibleWidth(text) > target ? truncateToWidth(text, target) : String(text ?? '')
|
|
return `${clipped}${' '.repeat(Math.max(0, target - visibleWidth(clipped)))}`
|
|
}
|
|
|
|
/** Enter the alternate screen buffer (restores the shell on leave). */
|
|
export const enterAlternateScreen = () => `${ESC}?1049h`
|
|
|
|
/** Leave the alternate screen buffer. */
|
|
export const leaveAlternateScreen = () => `${ESC}?1049l`
|
|
|
|
/** Hide the hardware cursor (during full redraws). */
|
|
export const hideCursor = () => `${ESC}?25l`
|
|
|
|
/** Show the hardware cursor. */
|
|
export const showCursor = () => `${ESC}?25h`
|
|
|
|
/** Move the cursor to a 1-based row/column (no clearing). */
|
|
export const moveTo = (row, column) => `${ESC}${row};${column}H`
|
|
|
|
/** Clear the whole screen and home the cursor. */
|
|
export const clearScreen = () => `${ESC}2J${ESC}H`
|
|
|
|
/** Erase from the cursor to the end of the current line. */
|
|
export const clearLine = () => `${ESC}K`
|
|
|
|
/** Current terminal size, with conservative defaults when not a TTY. */
|
|
export const terminalSize = ({ columns, rows } = {}) => ({
|
|
columns: Number.isInteger(columns) && columns > 0 ? columns : 80,
|
|
rows: Number.isInteger(rows) && rows > 0 ? rows : 24,
|
|
})
|
|
|
|
/** Wrap `text` in an SGR code and reset at the end (no-op on empty text). */
|
|
export const style = (code, text) => (text ? `${code}${text}${RESET}` : '')
|
|
|
|
/** Bold and dim shorthands over `style`. */
|
|
export const bold = (text) => style(SGR.bold, text)
|
|
export const dim = (text) => style(SGR.dim, text)
|
|
|
|
/**
|
|
* Severity → color for functional state reading (matches the UI draft):
|
|
* green = done/approved, yellow = waiting, red = blocked/error, blue/gray =
|
|
* neutral. Unknown states degrade to gray.
|
|
* @param {'ok'|'wait'|'bad'|'neutral'|string} tone
|
|
* @param {string} text
|
|
*/
|
|
export const tone = (toneName, text) => {
|
|
const code = {
|
|
ok: SGR.green,
|
|
wait: SGR.yellow,
|
|
bad: SGR.red,
|
|
accent: SGR.cyan,
|
|
neutral: SGR.gray,
|
|
}[toneName]
|
|
return style(code ?? SGR.gray, text)
|
|
}
|