@vigilbase/cli
Version:
Vigilbase CLI — security scanners, agent tools, and Vigilbase One from the terminal.
847 lines (846 loc) • 34.6 kB
JavaScript
/**
* 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('');
}