UNPKG

@vigilbase/cli

Version:

Vigilbase CLI — security scanners, agent tools, and Vigilbase One from the terminal.

847 lines (846 loc) • 34.6 kB
/** * A small terminal UI on raw ANSI, with no dependencies: a step timeline * (◆ active, ◇ done, │ rail, └ end, ▲ warning, ■ error), spinners, a progress * bar, and raw-mode inputs (text, code, select, confirm) that re-render in * place. It is used only when `terminalFeatures` finds an interactive * terminal; everything else keeps the plain line output. * * While a session is open it owns the terminal: raw mode (typed keys are not * echoed into the timeline, and Ctrl-C arrives as a key), a hidden cursor, and * SIGINT, SIGTERM and exit listeners that always restore both. Ctrl-C prints * the cancel message and is re-raised as SIGINT, so the other SIGINT listeners * (the cf runner and the cf install clean up, then exit 130) still run; with * none, the session exits 130 itself. */ import { StringDecoder } from 'node:string_decoder'; import { shouldUseColor } from './banner.js'; /** Set, and not `0` or `false`. */ function isSet(value) { return value !== undefined && value !== '' && value !== '0' && value.toLowerCase() !== 'false'; } /** * The rich UI's features, or null for the plain line output: `--json`, stdin * or stderr not a terminal (pipes, files), TERM=dumb, or CI set. */ export function terminalFeatures(options) { const { mode, stdin, stderr, env } = options; if (mode === 'json') return null; if (!stdin?.isTTY || typeof stdin.setRawMode !== 'function' || !stderr?.isTTY) return null; if ((env.TERM ?? '').toLowerCase() === 'dumb' || isSet(env.CI)) return null; return { color: shouldUseColor(stderr, env), unicode: supportsUnicode(env, options.platform ?? process.platform), hyperlinks: supportsHyperlinks(env), }; } /** Legacy Windows consoles (no Windows Terminal, no terminal program) and the Linux console get ASCII. */ export function supportsUnicode(env, platform = process.platform) { if (env.TERM === 'linux') return false; return platform !== 'win32' || Boolean(env.WT_SESSION) || Boolean(env.TERM_PROGRAM); } const HYPERLINK_PROGRAMS = new Set(['iTerm.app', 'WezTerm', 'vscode', 'ghostty', 'Tabby', 'rio']); /** Terminals known to render OSC 8 links; FORCE_HYPERLINK overrides. Multiplexers are left out. */ export function supportsHyperlinks(env) { if (env.FORCE_HYPERLINK !== undefined && env.FORCE_HYPERLINK !== '') return env.FORCE_HYPERLINK !== '0'; const term = env.TERM ?? ''; if (env.TMUX || term.startsWith('screen') || term.startsWith('tmux')) return false; if (env.WT_SESSION || env.KITTY_WINDOW_ID || env.WEZTERM_EXECUTABLE) return true; if (env.TERM_PROGRAM && HYPERLINK_PROGRAMS.has(env.TERM_PROGRAM)) return true; if (['xterm-kitty', 'xterm-ghostty', 'alacritty', 'foot', 'foot-extra'].includes(term)) return true; if (Number(env.VTE_VERSION) >= 5000) return true; return Number(env.KONSOLE_VERSION) >= 201200; } export const UNICODE_SYMBOLS = { active: '◆', done: '◇', rail: '│', end: '└', warning: '▲', error: '■', check: '✓', radioOn: '●', radioOff: '○', pointer: '›', ellipsis: '…', separator: '·', mask: '●', placeholder: '·', barFull: '█', barEmpty: '░', up: '↑', down: '↓', spinner: ['◒', '◐', '◓', '◑'], }; export const ASCII_SYMBOLS = { active: '*', done: 'o', rail: '|', end: '+', warning: '!', error: 'x', check: 'v', radioOn: '>', radioOff: ' ', pointer: '>', ellipsis: '...', separator: '-', mask: '*', placeholder: '.', barFull: '#', barEmpty: '-', up: '^', down: 'v', spinner: ['|', '/', '-', '\\'], }; const sgr = (open, close) => (text) => (text === '' ? '' : `\u001b[${open}m${text}\u001b[${close}m`); const plain = (text) => text; /** SGR styles; with colour off, every style is the identity (no escape sequences at all). */ export function createStyles(color) { if (!color) return { bold: plain, dim: plain, underline: plain, inverse: plain, green: plain, yellow: plain, red: plain, cyan: plain, brand: plain }; return { bold: sgr('1', '22'), dim: sgr('2', '22'), underline: sgr('4', '24'), inverse: sgr('7', '27'), green: sgr('32', '39'), yellow: sgr('33', '39'), red: sgr('31', '39'), cyan: sgr('36', '39'), // Brand yellow, as in the banner. brand: sgr('38;5;221', '39'), }; } // ============================================================================ // Measuring text // ============================================================================ const ESCAPE_SEQUENCE = /\u001b\[[0-?]*[ -/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g; const ZERO_WIDTH = /[\p{Mn}\p{Me}\p{Cf}\u0000-\u001f\u007f-\u009f\ufe00-\ufe0f]/u; const WIDE = /[\u1100-\u115f\u2e80-\u303e\u3041-\u33ff\u3400-\u4dbf\u4e00-\u9fff\ua000-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe30-\ufe4f\uff00-\uff60\uffe0-\uffe6\u{20000}-\u{3fffd}]|\p{Emoji_Presentation}/u; export function stripAnsi(text) { return text.replace(ESCAPE_SEQUENCE, ''); } /** Terminal columns of one character: 0 for marks and controls, 2 for CJK and emoji, else 1. */ export function charWidth(char) { if (ZERO_WIDTH.test(char)) return 0; return WIDE.test(char) ? 2 : 1; } /** Terminal columns of a line, ignoring escape sequences. */ export function displayWidth(text) { let width = 0; for (const char of stripAnsi(text)) width += charWidth(char); return width; } /** * At most `width` columns, ending in `…` when cut. Escape sequences are kept; * styles and an open OSC 8 link are closed after a cut. */ export function truncate(text, width, ellipsis = '…') { if (displayWidth(text) <= width) return text; const room = Math.max(0, width - displayWidth(ellipsis)); let out = ''; let used = 0; let styled = false; let linkOpen = false; const tokens = text.match(new RegExp(`${ESCAPE_SEQUENCE.source}|[\\s\\S]`, 'gu')) ?? []; for (const token of tokens) { if (token.startsWith('\u001b')) { out += token; if (token.startsWith('\u001b]8;')) linkOpen = !/^\u001b\]8;[^;]*;(?:\u0007|\u001b\\)$/.test(token); else styled = true; continue; } const w = charWidth(token); if (used + w > room) break; out += token; used += w; } return `${out}${linkOpen ? '\u001b]8;;\u001b\\' : ''}${styled ? '\u001b[0m' : ''}${width >= displayWidth(ellipsis) ? ellipsis : ''}`; } /** * Plain `text` (no escape sequences) in at most `width` columns, keeping its * start and its end around the ellipsis, so names that share a long prefix * (`Acme … Production`, `Acme … Staging`) still differ once cut. */ export function truncateMiddle(text, width, ellipsis = '…') { if (displayWidth(text) <= width) return text; const room = width - displayWidth(ellipsis); if (room < 2) return truncate(text, width, ellipsis); let head = ''; let used = 0; for (const char of text) { if (used + charWidth(char) > Math.ceil(room / 2)) break; head += char; used += charWidth(char); } return `${head}${ellipsis}${tail(text, room - used)}`; } /** `text` padded with spaces to `width` columns. */ export function padEnd(text, width) { return text + ' '.repeat(Math.max(0, width - displayWidth(text))); } const ESCAPE_KEY = /^\u001b(?:\[(?:[0-9;]*)[A-Za-z~]|O[A-Za-z])/; function escapeKey(sequence) { const final = sequence.slice(-1); if (final === '~') { const code = sequence.slice(2, -1).split(';')[0]; if (code === '3') return { name: 'delete' }; if (code === '1' || code === '7') return { name: 'home' }; if (code === '4' || code === '8') return { name: 'end' }; // Bracketed-paste markers (200~, 201~) and function keys. return { name: 'other' }; } switch (final) { case 'A': return { name: 'up' }; case 'B': return { name: 'down' }; case 'C': return { name: 'right' }; case 'D': return { name: 'left' }; case 'H': return { name: 'home' }; case 'F': return { name: 'end' }; case 'Z': return { name: 'tab' }; default: return { name: 'other' }; } } /** Keys in a chunk of raw-mode input; a paste arrives as many `char` keys. */ export function parseKeys(input) { const keys = []; for (let index = 0; index < input.length;) { if (input[index] === '\u001b') { const match = ESCAPE_KEY.exec(input.slice(index)); keys.push(match ? escapeKey(match[0]) : { name: 'escape' }); index += match ? match[0].length : 1; continue; } const char = String.fromCodePoint(input.codePointAt(index)); index += char.length; switch (char) { case '\r': case '\n': keys.push({ name: 'enter' }); if (char === '\r' && input[index] === '\n') index++; break; case '\u007f': case '\b': keys.push({ name: 'backspace' }); break; case '\u0003': keys.push({ name: 'ctrl-c' }); break; case '\u0004': keys.push({ name: 'ctrl-d' }); break; case '\u0015': keys.push({ name: 'ctrl-u' }); break; case '\u0017': keys.push({ name: 'ctrl-w' }); break; case '\t': keys.push({ name: 'tab' }); break; default: keys.push(charWidth(char) === 0 && /[\p{Cc}\p{Cf}]/u.test(char) ? { name: 'other' } : { name: 'char', char }); } } return keys; } // ============================================================================ // The live region // ============================================================================ /** * The lines at the bottom of the output that are redrawn in place: each * update moves the cursor back to the region's first line, clears to the end * of the screen and writes the new lines in a single write. Lines are cut to * the terminal width, so each takes one row; after a resize, the previous * lines are counted at the new width (terminals rewrap them). */ export class LiveRegion { out; ellipsis; lines = []; constructor(out, ellipsis = '…') { this.out = out; this.ellipsis = ellipsis; } /** The terminal width; 80 when it reports none (0 or undefined, as some pseudo-terminals do). */ get columns() { const columns = Math.floor(this.out.columns ?? 0); return columns > 0 ? Math.max(2, columns) : 80; } get rows() { const rows = Math.floor(this.out.rows ?? 0); return rows > 0 ? Math.max(6, rows) : 24; } erase() { if (this.lines.length === 0) return ''; const rows = this.lines.reduce((sum, line) => sum + Math.max(1, Math.ceil(displayWidth(line) / this.columns)), 0); return `\r${rows > 1 ? `\u001b[${rows - 1}A` : ''}\u001b[J`; } /** Lines that fit: one column short of the width (no pending wrap) and one row short of the height. */ fit(lines) { const width = this.columns - 1; const kept = lines.length > this.rows - 1 ? [...lines.slice(0, this.rows - 2), this.ellipsis] : lines; return kept.map((line) => truncate(line, width, this.ellipsis)); } draw(lines) { const next = this.fit(lines); this.out.write(this.erase() + next.join('\n')); this.lines = next; } /** Permanent lines above the region (never cut; long ones wrap), then the region again. */ commit(lines) { const live = this.lines; this.out.write(this.erase() + lines.map((line) => `${line}\n`).join('') + live.join('\n')); } clear() { if (this.lines.length === 0) return; this.out.write(this.erase()); this.lines = []; } get height() { return this.lines.length; } } const HIDE_CURSOR = '\u001b[?25l'; const SHOW_CURSOR = '\u001b[?25h'; export class Tui { symbols; style; features; /** What Ctrl-C (or SIGTERM) prints before the process exits. */ cancelMessage = 'Cancelled.'; stdin; stderr; region; signals; exitProcess; frameMs; decoder = new StringDecoder('utf8'); frame = null; animated = false; timer = null; tick = 0; keyHandler = null; reading = false; raw = false; suspended = false; closed = false; interrupted = false; constructor(options) { this.stdin = options.stdin; this.stderr = options.stderr; this.features = options.features; this.symbols = options.features.unicode ? UNICODE_SYMBOLS : ASCII_SYMBOLS; this.style = createStyles(options.features.color); this.region = new LiveRegion(options.stderr, this.symbols.ellipsis); this.signals = options.signals ?? process; this.exitProcess = options.exit ?? ((code) => process.exit(code)); this.frameMs = options.frameMs ?? 80; this.signals.on('SIGINT', this.onSigint); this.signals.on('SIGTERM', this.onSigterm); this.signals.on('exit', this.onExit); this.stderr.on?.('resize', this.onResize); this.stderr.write(HIDE_CURSOR); this.attachInput(); } /** True once the user cancelled (Ctrl-C or SIGTERM); nothing is drawn after that. */ get isInterrupted() { return this.interrupted; } get columns() { return this.region.columns; } // -------------------------------------------------------------------------- // Output // -------------------------------------------------------------------------- /** Permanent lines above the live frame. */ print(...lines) { if (this.closed) return; if (this.suspended) this.stderr.write(lines.map((line) => `${line}\n`).join('')); else this.region.commit(lines); } /** `│` */ rail() { this.print(this.style.dim(this.symbols.rail)); } /** `◇ title detail` */ step(title, detail) { this.print(`${this.style.green(this.symbols.done)} ${title}${detail ? ` ${detail}` : ''}`); } /** `◇ title › value` (an answered question). */ answered(title, value) { this.print(`${this.style.green(this.symbols.done)} ${title} ${this.style.dim(this.symbols.pointer)} ${value}`); } /** `│ text`, wrapped at word boundaries (`text` is plain). */ info(text) { this.print(...this.wrap(text, 3).map((part) => `${this.style.dim(this.symbols.rail)} ${part}`)); } /** `▲ text` */ warn(text) { this.print(`${this.style.yellow(this.symbols.warning)} ${text}`); } /** `■ text`, then dim detail lines under it. */ error(text, ...details) { this.print(`${this.style.red(this.symbols.error)} ${text}`, ...details.flatMap((line) => this.wrap(line, 3).map((part) => ` ${this.style.dim(part)}`))); } /** `│ text` (dim): a note inside the current step, wrapped at word boundaries. */ note(text) { this.print(...this.wrap(text, 3).map((part) => `${this.style.dim(this.symbols.rail)} ${this.style.dim(part)}`)); } /** * A command the user may copy, indented and on a line of its own: never * wrapped, cut or prefixed (a long one is soft-wrapped by the terminal and * still copies as one line). */ command(text) { this.print(` ${text}`); } /** Dim lines under the end of the timeline (after `└`), indented and wrapped. */ footnote(text) { this.print(...this.wrap(text, 3).map((part) => ` ${this.style.dim(part)}`)); } /** Plain text in lines that fit after an `indent`-column prefix (a longer word, such as a path, stays whole). */ wrap(text, indent) { return wrapWords(text, Math.max(20, this.columns - 1 - indent)); } /** The current spinner frame. */ get spin() { return this.style.cyan(this.symbols.spinner[this.tick % this.symbols.spinner.length]); } /** `███░░░` for a ratio between 0 and 1. */ bar(ratio, width) { const filled = Math.round(Math.max(0, Math.min(1, ratio)) * width); return this.style.cyan(this.symbols.barFull.repeat(filled)) + this.style.dim(this.symbols.barEmpty.repeat(width - filled)); } /** The items of a summary line: `a · b · c`, or `a - b - c` in ASCII. */ join(parts) { return parts.join(` ${this.symbols.separator} `); } /** * Text written elsewhere with the Unicode symbols (an error message with a * masked ID, a warning) in this session's: on an ASCII-only console, `…` * becomes `...` and ` · ` becomes ` - `. */ symbolText(text) { if (this.features.unicode) return text; return text.replace(/…/g, this.symbols.ellipsis).replace(/ · /g, ` ${this.symbols.separator} `); } /** An OSC 8 link where the terminal supports it, else the text. The URL must be printable ASCII. */ link(url, text = url) { return this.features.hyperlinks ? `\u001b]8;;${url}\u001b\\${text}\u001b]8;;\u001b\\` : text; } /** The live frame, replacing the previous one; animated frames are redrawn every frame (spinners). */ show(render, options = {}) { this.frame = render; this.animated = Boolean(options.animated); this.syncTimer(); this.draw(); } clearFrame() { this.frame = null; this.animated = false; this.syncTimer(); if (!this.closed && !this.suspended) this.region.clear(); } /** Redraw the live frame now (after its state changed). */ draw() { if (this.closed || this.suspended) return; if (this.frame) this.region.draw(this.frame()); else this.region.clear(); } /** `◒ label` until stopped. */ spinner(label) { let text = label; let stopped = false; const render = () => [`${this.spin} ${text}`]; this.show(render, { animated: true }); return { update: (next) => { text = next; if (!stopped && this.frame === render) this.draw(); }, stop: () => { if (stopped) return; stopped = true; if (this.frame === render) this.clearFrame(); }, }; } /** `work` with a spinner. */ async busy(label, work) { const spinner = this.spinner(label); try { return await work(); } finally { spinner.stop(); } } syncTimer() { const wanted = this.animated && !this.suspended && !this.closed; if (wanted && !this.timer) { this.timer = setInterval(() => { this.tick++; this.draw(); }, this.frameMs); this.timer.unref?.(); } else if (!wanted && this.timer) { clearInterval(this.timer); this.timer = null; } } // -------------------------------------------------------------------------- // Inputs // -------------------------------------------------------------------------- /** Run a prompt until a key ends it; its frame is cleared afterwards. Ctrl-C never ends it (it cancels the run). */ prompt(view) { if (this.closed) return new Promise(() => undefined); return new Promise((resolve) => { this.keyHandler = (key) => { const outcome = view.key(key); if (!outcome) { this.draw(); return; } this.keyHandler = null; this.clearFrame(); resolve(outcome.value); }; this.show(() => view.render()); }); } /** The fake cursor of inputs (the real one stays hidden). */ get cursor() { return this.features.color ? this.style.inverse(' ') : '_'; } head(title, value) { return `${this.style.cyan(this.symbols.active)} ${title} ${this.style.dim(this.symbols.pointer)} ${value}`; } /** Columns an input's first line has for its title and value together (the frame's width less `◆ ` and ` › `). */ get headRoom() { return this.columns - 1 - displayWidth(this.head('', '')); } /** A plain title cut (start and end kept) to at most `width` columns, so the value after it stays on screen. */ fitTitle(title, width) { return truncateMiddle(title, Math.max(1, width), this.symbols.ellipsis); } noticeLines(notice) { return notice ? [`${this.style.yellow(this.symbols.rail)} ${this.style.yellow(notice)}`] : []; } /** * A one-line text input: printable characters, Backspace, Ctrl-U (clear) and * Ctrl-W (last word). Null on Ctrl-D with no text (the input ended). On a * narrow terminal the (plain) title is cut before the value, down to a third * of the line; a value wider than the rest shows its end. */ text(options) { const max = options.maxLength ?? 256; let value = ''; return this.prompt({ render: () => { const available = this.headRoom; const wanted = displayWidth(value) + 2; const titleWidth = Math.min(displayWidth(options.title), Math.max(available - wanted, Math.floor(available / 3))); const title = this.fitTitle(options.title, titleWidth); const room = Math.max(8, available - displayWidth(title) - 2); const shown = displayWidth(value) > room ? `${this.symbols.ellipsis}${tail(value, room - displayWidth(this.symbols.ellipsis))}` : value; return [this.head(title, `${shown}${this.cursor}`), ...this.noticeLines(options.notice)]; }, key: (key) => { switch (key.name) { case 'enter': return { value }; case 'ctrl-d': return value === '' ? { value: null } : undefined; case 'backspace': value = [...value].slice(0, -1).join(''); return undefined; case 'ctrl-u': value = ''; return undefined; case 'ctrl-w': value = value.replace(/\S*\s*$/u, ''); return undefined; case 'char': if (charWidth(key.char) > 0 && [...value].length < max) value += key.char; return undefined; default: return undefined; } }, }); } /** * A numeric code input: digits are shown as typed (spaces and dashes, typed * or pasted, are skipped) and the input ends at the last digit. Enter with * fewer digits keeps it open. Null on Ctrl-D (the input ended). On a narrow * terminal the (plain) title is cut, never the digits. */ code(options) { let digits = ''; let short = false; return this.prompt({ render: () => { const rest = this.style.dim(this.symbols.placeholder.repeat(Math.max(0, options.length - digits.length - 1))); const value = `${digits}${digits.length < options.length ? this.cursor : ''}${rest}`; return [ this.head(this.fitTitle(options.title, this.headRoom - displayWidth(value)), value), ...(options.hint ? [`${this.style.dim(this.symbols.rail)} ${this.style.dim(options.hint)}`] : []), ...this.noticeLines(short ? `Enter all ${options.length} digits.` : options.notice), ]; }, key: (key) => { if (key.name === 'ctrl-d') return { value: null }; if (key.name === 'backspace') digits = digits.slice(0, -1); else if (key.name === 'ctrl-u') digits = ''; else if (key.name === 'enter') short = digits.length < options.length; else if (key.name === 'char' && /^\d$/.test(key.char)) { digits += key.char; short = false; } return digits.length === options.length ? { value: digits } : undefined; }, }); } /** * An arrow-key list (↑/↓ or j/k, Home/End, Enter). Null on Ctrl-D. Labels * are plain text, padded to one column; on a narrow terminal each is cut in * the middle so its detail (a masked ID) always stays on screen. */ select(options) { const { items } = options; let index = Math.max(0, Math.min(items.length - 1, options.initial ?? 0)); let top = 0; const widest = Math.max(0, ...items.map((item) => displayWidth(item.label))); const detailWidth = Math.max(0, ...items.map((item) => (item.detail ? displayWidth(item.detail) + 2 : 0))); return this.prompt({ render: () => { const visible = Math.max(3, Math.min(items.length, this.region.rows - 5, 10)); if (index < top) top = index; if (index >= top + visible) top = index - visible + 1; const rail = this.style.dim(this.symbols.rail); const prefix = displayWidth(`${this.symbols.rail} ${this.symbols.radioOn} `); const labelWidth = Math.min(widest, Math.max(4, this.columns - 1 - prefix - detailWidth)); const rows = items.slice(top, top + visible).map((item, offset) => { const active = top + offset === index; const mark = active ? this.style.green(this.symbols.radioOn) : this.style.dim(this.symbols.radioOff); const fitted = padEnd(truncateMiddle(item.label, labelWidth, this.symbols.ellipsis), labelWidth); const label = active ? this.style.bold(fitted) : fitted; return `${rail} ${mark} ${label}${item.detail ? ` ${this.style.dim(item.detail)}` : ''}`; }); const above = top > 0 ? [`${rail} ${this.style.dim(`${this.symbols.up} ${top} more`)}`] : []; const below = top + visible < items.length ? [`${rail} ${this.style.dim(`${this.symbols.down} ${items.length - top - visible} more`)}`] : []; return [`${this.style.cyan(this.symbols.active)} ${options.title}${options.hint ? ` ${this.style.dim(options.hint)}` : ''}`, ...above, ...rows, ...below]; }, key: (key) => { if (key.name === 'enter') return { value: items[index].value }; if (key.name === 'ctrl-d') return { value: null }; if (key.name === 'up' || (key.name === 'char' && key.char === 'k')) index = (index - 1 + items.length) % items.length; else if (key.name === 'down' || key.name === 'tab' || (key.name === 'char' && key.char === 'j')) index = (index + 1) % items.length; else if (key.name === 'home') index = 0; else if (key.name === 'end') index = items.length - 1; return undefined; }, }); } /** Yes / No: ←/→ (or Tab, h/l) move, y or n answers at once, Enter answers the highlighted choice. Null on Ctrl-D. */ confirm(options) { let yes = options.initial; const choice = (label, on) => { if (!this.features.color) return on ? `[${label}]` : label; return on ? this.style.underline(this.style.cyan(label)) : this.style.dim(label); }; return this.prompt({ render: () => [ `${this.style.cyan(this.symbols.active)} ${options.title} ${this.style.dim(this.symbols.pointer)} ${choice('Yes', yes)} ${this.style.dim('/')} ${choice('No', !yes)}`, ...(options.detail ? [`${this.style.dim(this.symbols.rail)} ${this.style.dim(options.detail)}`] : []), ], key: (key) => { if (key.name === 'enter') return { value: yes }; if (key.name === 'ctrl-d') return { value: null }; if (key.name === 'char' && /^[yY]$/.test(key.char)) return { value: true }; if (key.name === 'char' && /^[nN]$/.test(key.char)) return { value: false }; if (['left', 'right', 'tab'].includes(key.name) || (key.name === 'char' && /^[hl]$/.test(key.char))) yes = !yes; return undefined; }, }); } // -------------------------------------------------------------------------- // The terminal // -------------------------------------------------------------------------- onData = (chunk) => { const text = typeof chunk === 'string' ? chunk : this.decoder.write(chunk); for (const key of parseKeys(text)) { if (key.name === 'ctrl-c') { this.interrupt(); return; } // Keys typed while nothing asks are dropped (and were never echoed). this.keyHandler?.(key); } }; /** Ctrl-C in raw mode sends no signal: raise SIGINT for every listener, this session's included. */ interrupt() { this.signals.emit('SIGINT', 'SIGINT'); } setRaw(on) { if (this.raw === on) return; this.raw = on; try { this.stdin.setRawMode?.(on); } catch { // The terminal is already gone. } } attachInput() { if (this.reading) return; this.reading = true; this.setRaw(true); this.stdin.on('data', this.onData); this.stdin.resume(); } detachInput() { if (!this.reading) return; this.reading = false; this.stdin.removeListener('data', this.onData); this.stdin.pause(); this.setRaw(false); } /** * Hand the terminal to another program (cf's interactive sign-in): the * frame is cleared, raw mode is off, input is released and the cursor shown. */ suspend() { if (this.closed || this.suspended) return; this.region.clear(); this.suspended = true; this.syncTimer(); this.detachInput(); this.stderr.write(SHOW_CURSOR); } resume() { if (this.closed || !this.suspended) return; this.suspended = false; this.stderr.write(HIDE_CURSOR); this.attachInput(); this.syncTimer(); this.draw(); } /** `work` with the terminal handed over (see `suspend`). */ async handOver(work) { this.suspend(); try { return await work(); } finally { this.resume(); } } onResize = () => { this.draw(); }; /** Raw mode off, the frame cleared, the cursor shown; nothing more is drawn. */ restore() { if (this.closed) return; this.closed = true; if (this.timer) clearInterval(this.timer); this.timer = null; this.keyHandler = null; if (!this.suspended) this.region.clear(); this.detachInput(); this.stderr.write(SHOW_CURSOR); this.stderr.removeListener?.('resize', this.onResize); } detachSignals() { this.signals.removeListener('SIGINT', this.onSigint); this.signals.removeListener('SIGTERM', this.onSigterm); this.signals.removeListener('exit', this.onExit); } /** Print the cancel message; exit with `code` unless another listener for `signal` will (it exits 130 after cleaning up). */ cancel(signal, code) { if (this.interrupted || this.closed) return; this.interrupted = true; this.restore(); this.stderr.write(`${this.style.red(this.symbols.error)} ${this.cancelMessage}\n`); this.detachSignals(); if (this.signals.listenerCount(signal) === 0) this.exitProcess(code); } onSigint = () => this.cancel('SIGINT', 130); onSigterm = () => this.cancel('SIGTERM', 143); onExit = () => this.restore(); /** End the session: the terminal is restored and the listeners removed. Safe to call twice. */ close() { this.restore(); this.detachSignals(); } } const renderedErrors = new WeakSet(); /** The terminal UI has shown this error (■ and its hint); `runCli` does not print it again. */ export function markRendered(error) { renderedErrors.add(error); } export function wasRendered(error) { return typeof error === 'object' && error !== null && renderedErrors.has(error); } /** * Words in lines of at most `width` columns; a longer word gets a line of its * own. Leading spaces (an indented line) are kept on every line. */ export function wrapWords(text, width) { const indent = /^ */.exec(text)[0]; const lines = []; let line = ''; for (const word of text.slice(indent.length).split(' ')) { const next = line === '' ? word : `${line} ${word}`; if (line !== '' && displayWidth(indent + next) > width) { lines.push(indent + line); line = word; } else { line = next; } } lines.push(indent + line); return lines; } /** The last `width` columns of `text`. */ function tail(text, width) { const chars = [...text]; let used = 0; let start = chars.length; while (start > 0 && used + charWidth(chars[start - 1]) <= width) used += charWidth(chars[--start]); return chars.slice(start).join(''); }