@vigilbase/cli 0.1.4 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLOUDFLARE.md +2 -0
- package/COMMANDS.md +43 -6
- package/LOCAL.md +731 -0
- package/README.md +58 -6
- package/dist/auth/device-auth.d.ts.map +1 -1
- package/dist/auth/device-auth.js +7 -3
- package/dist/auth/device-auth.js.map +1 -1
- package/dist/auth/index.d.ts +3 -0
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +2 -0
- package/dist/auth/index.js.map +1 -1
- package/dist/auth/provider.d.ts +18 -0
- package/dist/auth/provider.d.ts.map +1 -0
- package/dist/auth/provider.js +62 -0
- package/dist/auth/provider.js.map +1 -0
- package/dist/auth/refresh.d.ts +10 -0
- package/dist/auth/refresh.d.ts.map +1 -0
- package/dist/auth/refresh.js +143 -0
- package/dist/auth/refresh.js.map +1 -0
- package/dist/auth/store.d.ts +27 -2
- package/dist/auth/store.d.ts.map +1 -1
- package/dist/auth/store.js +23 -8
- package/dist/auth/store.js.map +1 -1
- package/dist/auth/token-metadata.d.ts +10 -0
- package/dist/auth/token-metadata.d.ts.map +1 -0
- package/dist/auth/token-metadata.js +22 -0
- package/dist/auth/token-metadata.js.map +1 -0
- package/dist/auth/types.d.ts +8 -1
- package/dist/auth/types.d.ts.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +40 -9
- package/dist/cli.js.map +1 -1
- package/dist/commands/auth.d.ts.map +1 -1
- package/dist/commands/auth.js +20 -8
- package/dist/commands/auth.js.map +1 -1
- package/dist/commands/helpers.d.ts +15 -0
- package/dist/commands/helpers.d.ts.map +1 -1
- package/dist/commands/helpers.js +2 -2
- package/dist/commands/helpers.js.map +1 -1
- package/dist/commands/index.d.ts +3 -1
- package/dist/commands/index.d.ts.map +1 -1
- package/dist/commands/index.js +6 -1
- package/dist/commands/index.js.map +1 -1
- package/dist/commands/local.d.ts +30 -0
- package/dist/commands/local.d.ts.map +1 -0
- package/dist/commands/local.js +1520 -0
- package/dist/commands/local.js.map +1 -0
- package/dist/commands/public-tools.d.ts.map +1 -1
- package/dist/commands/public-tools.js +8 -4
- package/dist/commands/public-tools.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/config.d.ts +16 -1
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +36 -4
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/format.d.ts.map +1 -1
- package/dist/lib/format.js +9 -0
- package/dist/lib/format.js.map +1 -1
- package/dist/lib/help.d.ts +1 -1
- package/dist/lib/help.d.ts.map +1 -1
- package/dist/lib/help.js +9 -0
- package/dist/lib/help.js.map +1 -1
- package/dist/lib/network.d.ts.map +1 -1
- package/dist/lib/network.js +2 -1
- package/dist/lib/network.js.map +1 -1
- package/dist/lib/one-client.d.ts +3 -2
- package/dist/lib/one-client.d.ts.map +1 -1
- package/dist/lib/one-client.js +46 -2
- package/dist/lib/one-client.js.map +1 -1
- package/dist/lib/security-client.d.ts +1 -1
- package/dist/lib/tui.d.ts +309 -0
- package/dist/lib/tui.d.ts.map +1 -0
- package/dist/lib/tui.js +847 -0
- package/dist/lib/tui.js.map +1 -0
- package/dist/local/cloudflare-checkup/cf/allowlist.d.ts +95 -0
- package/dist/local/cloudflare-checkup/cf/allowlist.d.ts.map +1 -0
- package/dist/local/cloudflare-checkup/cf/allowlist.js +184 -0
- package/dist/local/cloudflare-checkup/cf/allowlist.js.map +1 -0
- package/dist/local/cloudflare-checkup/cf/auth-lifecycle.d.ts +113 -0
- package/dist/local/cloudflare-checkup/cf/auth-lifecycle.d.ts.map +1 -0
- package/dist/local/cloudflare-checkup/cf/auth-lifecycle.js +313 -0
- package/dist/local/cloudflare-checkup/cf/auth-lifecycle.js.map +1 -0
- package/dist/local/cloudflare-checkup/cf/child-env.d.ts +32 -0
- package/dist/local/cloudflare-checkup/cf/child-env.d.ts.map +1 -0
- package/dist/local/cloudflare-checkup/cf/child-env.js +81 -0
- package/dist/local/cloudflare-checkup/cf/child-env.js.map +1 -0
- package/dist/local/cloudflare-checkup/cf/install.d.ts +106 -0
- package/dist/local/cloudflare-checkup/cf/install.d.ts.map +1 -0
- package/dist/local/cloudflare-checkup/cf/install.js +309 -0
- package/dist/local/cloudflare-checkup/cf/install.js.map +1 -0
- package/dist/local/cloudflare-checkup/cf/locate.d.ts +44 -0
- package/dist/local/cloudflare-checkup/cf/locate.d.ts.map +1 -0
- package/dist/local/cloudflare-checkup/cf/locate.js +228 -0
- package/dist/local/cloudflare-checkup/cf/locate.js.map +1 -0
- package/dist/local/cloudflare-checkup/cf/run-cf.d.ts +89 -0
- package/dist/local/cloudflare-checkup/cf/run-cf.d.ts.map +1 -0
- package/dist/local/cloudflare-checkup/cf/run-cf.js +275 -0
- package/dist/local/cloudflare-checkup/cf/run-cf.js.map +1 -0
- package/dist/local/cloudflare-checkup/collector.d.ts +48 -0
- package/dist/local/cloudflare-checkup/collector.d.ts.map +1 -0
- package/dist/local/cloudflare-checkup/collector.js +296 -0
- package/dist/local/cloudflare-checkup/collector.js.map +1 -0
- package/dist/local/cloudflare-checkup/contract.d.ts +2793 -0
- package/dist/local/cloudflare-checkup/contract.d.ts.map +1 -0
- package/dist/local/cloudflare-checkup/contract.js +1614 -0
- package/dist/local/cloudflare-checkup/contract.js.map +1 -0
- package/dist/local/cloudflare-checkup/email-verification.d.ts +125 -0
- package/dist/local/cloudflare-checkup/email-verification.d.ts.map +1 -0
- package/dist/local/cloudflare-checkup/email-verification.js +212 -0
- package/dist/local/cloudflare-checkup/email-verification.js.map +1 -0
- package/dist/local/cloudflare-checkup/errors.d.ts +28 -0
- package/dist/local/cloudflare-checkup/errors.d.ts.map +1 -0
- package/dist/local/cloudflare-checkup/errors.js +99 -0
- package/dist/local/cloudflare-checkup/errors.js.map +1 -0
- package/dist/local/cloudflare-checkup/output.d.ts +24 -0
- package/dist/local/cloudflare-checkup/output.d.ts.map +1 -0
- package/dist/local/cloudflare-checkup/output.js +89 -0
- package/dist/local/cloudflare-checkup/output.js.map +1 -0
- package/dist/local/cloudflare-checkup/prompts.d.ts +41 -0
- package/dist/local/cloudflare-checkup/prompts.d.ts.map +1 -0
- package/dist/local/cloudflare-checkup/prompts.js +140 -0
- package/dist/local/cloudflare-checkup/prompts.js.map +1 -0
- package/dist/local/cloudflare-checkup/rate-budget.d.ts +42 -0
- package/dist/local/cloudflare-checkup/rate-budget.d.ts.map +1 -0
- package/dist/local/cloudflare-checkup/rate-budget.js +80 -0
- package/dist/local/cloudflare-checkup/rate-budget.js.map +1 -0
- package/dist/local/cloudflare-checkup/snapshot-file.d.ts +53 -0
- package/dist/local/cloudflare-checkup/snapshot-file.d.ts.map +1 -0
- package/dist/local/cloudflare-checkup/snapshot-file.js +186 -0
- package/dist/local/cloudflare-checkup/snapshot-file.js.map +1 -0
- package/dist/local/cloudflare-checkup/submit-client.d.ts +98 -0
- package/dist/local/cloudflare-checkup/submit-client.d.ts.map +1 -0
- package/dist/local/cloudflare-checkup/submit-client.js +349 -0
- package/dist/local/cloudflare-checkup/submit-client.js.map +1 -0
- package/dist/local/cloudflare-checkup/terminal-ui.d.ts +171 -0
- package/dist/local/cloudflare-checkup/terminal-ui.d.ts.map +1 -0
- package/dist/local/cloudflare-checkup/terminal-ui.js +404 -0
- package/dist/local/cloudflare-checkup/terminal-ui.js.map +1 -0
- package/package.json +8 -4
package/dist/lib/tui.js
ADDED
|
@@ -0,0 +1,847 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small terminal UI on raw ANSI, with no dependencies: a step timeline
|
|
3
|
+
* (◆ active, ◇ done, │ rail, └ end, ▲ warning, ■ error), spinners, a progress
|
|
4
|
+
* bar, and raw-mode inputs (text, code, select, confirm) that re-render in
|
|
5
|
+
* place. It is used only when `terminalFeatures` finds an interactive
|
|
6
|
+
* terminal; everything else keeps the plain line output.
|
|
7
|
+
*
|
|
8
|
+
* While a session is open it owns the terminal: raw mode (typed keys are not
|
|
9
|
+
* echoed into the timeline, and Ctrl-C arrives as a key), a hidden cursor, and
|
|
10
|
+
* SIGINT, SIGTERM and exit listeners that always restore both. Ctrl-C prints
|
|
11
|
+
* the cancel message and is re-raised as SIGINT, so the other SIGINT listeners
|
|
12
|
+
* (the cf runner and the cf install clean up, then exit 130) still run; with
|
|
13
|
+
* none, the session exits 130 itself.
|
|
14
|
+
*/
|
|
15
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
16
|
+
import { shouldUseColor } from './banner.js';
|
|
17
|
+
/** Set, and not `0` or `false`. */
|
|
18
|
+
function isSet(value) {
|
|
19
|
+
return value !== undefined && value !== '' && value !== '0' && value.toLowerCase() !== 'false';
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The rich UI's features, or null for the plain line output: `--json`, stdin
|
|
23
|
+
* or stderr not a terminal (pipes, files), TERM=dumb, or CI set.
|
|
24
|
+
*/
|
|
25
|
+
export function terminalFeatures(options) {
|
|
26
|
+
const { mode, stdin, stderr, env } = options;
|
|
27
|
+
if (mode === 'json')
|
|
28
|
+
return null;
|
|
29
|
+
if (!stdin?.isTTY || typeof stdin.setRawMode !== 'function' || !stderr?.isTTY)
|
|
30
|
+
return null;
|
|
31
|
+
if ((env.TERM ?? '').toLowerCase() === 'dumb' || isSet(env.CI))
|
|
32
|
+
return null;
|
|
33
|
+
return {
|
|
34
|
+
color: shouldUseColor(stderr, env),
|
|
35
|
+
unicode: supportsUnicode(env, options.platform ?? process.platform),
|
|
36
|
+
hyperlinks: supportsHyperlinks(env),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** Legacy Windows consoles (no Windows Terminal, no terminal program) and the Linux console get ASCII. */
|
|
40
|
+
export function supportsUnicode(env, platform = process.platform) {
|
|
41
|
+
if (env.TERM === 'linux')
|
|
42
|
+
return false;
|
|
43
|
+
return platform !== 'win32' || Boolean(env.WT_SESSION) || Boolean(env.TERM_PROGRAM);
|
|
44
|
+
}
|
|
45
|
+
const HYPERLINK_PROGRAMS = new Set(['iTerm.app', 'WezTerm', 'vscode', 'ghostty', 'Tabby', 'rio']);
|
|
46
|
+
/** Terminals known to render OSC 8 links; FORCE_HYPERLINK overrides. Multiplexers are left out. */
|
|
47
|
+
export function supportsHyperlinks(env) {
|
|
48
|
+
if (env.FORCE_HYPERLINK !== undefined && env.FORCE_HYPERLINK !== '')
|
|
49
|
+
return env.FORCE_HYPERLINK !== '0';
|
|
50
|
+
const term = env.TERM ?? '';
|
|
51
|
+
if (env.TMUX || term.startsWith('screen') || term.startsWith('tmux'))
|
|
52
|
+
return false;
|
|
53
|
+
if (env.WT_SESSION || env.KITTY_WINDOW_ID || env.WEZTERM_EXECUTABLE)
|
|
54
|
+
return true;
|
|
55
|
+
if (env.TERM_PROGRAM && HYPERLINK_PROGRAMS.has(env.TERM_PROGRAM))
|
|
56
|
+
return true;
|
|
57
|
+
if (['xterm-kitty', 'xterm-ghostty', 'alacritty', 'foot', 'foot-extra'].includes(term))
|
|
58
|
+
return true;
|
|
59
|
+
if (Number(env.VTE_VERSION) >= 5000)
|
|
60
|
+
return true;
|
|
61
|
+
return Number(env.KONSOLE_VERSION) >= 201200;
|
|
62
|
+
}
|
|
63
|
+
export const UNICODE_SYMBOLS = {
|
|
64
|
+
active: '◆', done: '◇', rail: '│', end: '└', warning: '▲', error: '■', check: '✓',
|
|
65
|
+
radioOn: '●', radioOff: '○', pointer: '›', ellipsis: '…', separator: '·', mask: '●', placeholder: '·',
|
|
66
|
+
barFull: '█', barEmpty: '░', up: '↑', down: '↓', spinner: ['◒', '◐', '◓', '◑'],
|
|
67
|
+
};
|
|
68
|
+
export const ASCII_SYMBOLS = {
|
|
69
|
+
active: '*', done: 'o', rail: '|', end: '+', warning: '!', error: 'x', check: 'v',
|
|
70
|
+
radioOn: '>', radioOff: ' ', pointer: '>', ellipsis: '...', separator: '-', mask: '*', placeholder: '.',
|
|
71
|
+
barFull: '#', barEmpty: '-', up: '^', down: 'v', spinner: ['|', '/', '-', '\\'],
|
|
72
|
+
};
|
|
73
|
+
const sgr = (open, close) => (text) => (text === '' ? '' : `\u001b[${open}m${text}\u001b[${close}m`);
|
|
74
|
+
const plain = (text) => text;
|
|
75
|
+
/** SGR styles; with colour off, every style is the identity (no escape sequences at all). */
|
|
76
|
+
export function createStyles(color) {
|
|
77
|
+
if (!color)
|
|
78
|
+
return { bold: plain, dim: plain, underline: plain, inverse: plain, green: plain, yellow: plain, red: plain, cyan: plain, brand: plain };
|
|
79
|
+
return {
|
|
80
|
+
bold: sgr('1', '22'),
|
|
81
|
+
dim: sgr('2', '22'),
|
|
82
|
+
underline: sgr('4', '24'),
|
|
83
|
+
inverse: sgr('7', '27'),
|
|
84
|
+
green: sgr('32', '39'),
|
|
85
|
+
yellow: sgr('33', '39'),
|
|
86
|
+
red: sgr('31', '39'),
|
|
87
|
+
cyan: sgr('36', '39'),
|
|
88
|
+
// Brand yellow, as in the banner.
|
|
89
|
+
brand: sgr('38;5;221', '39'),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
// ============================================================================
|
|
93
|
+
// Measuring text
|
|
94
|
+
// ============================================================================
|
|
95
|
+
const ESCAPE_SEQUENCE = /\u001b\[[0-?]*[ -/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g;
|
|
96
|
+
const ZERO_WIDTH = /[\p{Mn}\p{Me}\p{Cf}\u0000-\u001f\u007f-\u009f\ufe00-\ufe0f]/u;
|
|
97
|
+
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;
|
|
98
|
+
export function stripAnsi(text) {
|
|
99
|
+
return text.replace(ESCAPE_SEQUENCE, '');
|
|
100
|
+
}
|
|
101
|
+
/** Terminal columns of one character: 0 for marks and controls, 2 for CJK and emoji, else 1. */
|
|
102
|
+
export function charWidth(char) {
|
|
103
|
+
if (ZERO_WIDTH.test(char))
|
|
104
|
+
return 0;
|
|
105
|
+
return WIDE.test(char) ? 2 : 1;
|
|
106
|
+
}
|
|
107
|
+
/** Terminal columns of a line, ignoring escape sequences. */
|
|
108
|
+
export function displayWidth(text) {
|
|
109
|
+
let width = 0;
|
|
110
|
+
for (const char of stripAnsi(text))
|
|
111
|
+
width += charWidth(char);
|
|
112
|
+
return width;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* At most `width` columns, ending in `…` when cut. Escape sequences are kept;
|
|
116
|
+
* styles and an open OSC 8 link are closed after a cut.
|
|
117
|
+
*/
|
|
118
|
+
export function truncate(text, width, ellipsis = '…') {
|
|
119
|
+
if (displayWidth(text) <= width)
|
|
120
|
+
return text;
|
|
121
|
+
const room = Math.max(0, width - displayWidth(ellipsis));
|
|
122
|
+
let out = '';
|
|
123
|
+
let used = 0;
|
|
124
|
+
let styled = false;
|
|
125
|
+
let linkOpen = false;
|
|
126
|
+
const tokens = text.match(new RegExp(`${ESCAPE_SEQUENCE.source}|[\\s\\S]`, 'gu')) ?? [];
|
|
127
|
+
for (const token of tokens) {
|
|
128
|
+
if (token.startsWith('\u001b')) {
|
|
129
|
+
out += token;
|
|
130
|
+
if (token.startsWith('\u001b]8;'))
|
|
131
|
+
linkOpen = !/^\u001b\]8;[^;]*;(?:\u0007|\u001b\\)$/.test(token);
|
|
132
|
+
else
|
|
133
|
+
styled = true;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const w = charWidth(token);
|
|
137
|
+
if (used + w > room)
|
|
138
|
+
break;
|
|
139
|
+
out += token;
|
|
140
|
+
used += w;
|
|
141
|
+
}
|
|
142
|
+
return `${out}${linkOpen ? '\u001b]8;;\u001b\\' : ''}${styled ? '\u001b[0m' : ''}${width >= displayWidth(ellipsis) ? ellipsis : ''}`;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Plain `text` (no escape sequences) in at most `width` columns, keeping its
|
|
146
|
+
* start and its end around the ellipsis, so names that share a long prefix
|
|
147
|
+
* (`Acme … Production`, `Acme … Staging`) still differ once cut.
|
|
148
|
+
*/
|
|
149
|
+
export function truncateMiddle(text, width, ellipsis = '…') {
|
|
150
|
+
if (displayWidth(text) <= width)
|
|
151
|
+
return text;
|
|
152
|
+
const room = width - displayWidth(ellipsis);
|
|
153
|
+
if (room < 2)
|
|
154
|
+
return truncate(text, width, ellipsis);
|
|
155
|
+
let head = '';
|
|
156
|
+
let used = 0;
|
|
157
|
+
for (const char of text) {
|
|
158
|
+
if (used + charWidth(char) > Math.ceil(room / 2))
|
|
159
|
+
break;
|
|
160
|
+
head += char;
|
|
161
|
+
used += charWidth(char);
|
|
162
|
+
}
|
|
163
|
+
return `${head}${ellipsis}${tail(text, room - used)}`;
|
|
164
|
+
}
|
|
165
|
+
/** `text` padded with spaces to `width` columns. */
|
|
166
|
+
export function padEnd(text, width) {
|
|
167
|
+
return text + ' '.repeat(Math.max(0, width - displayWidth(text)));
|
|
168
|
+
}
|
|
169
|
+
const ESCAPE_KEY = /^\u001b(?:\[(?:[0-9;]*)[A-Za-z~]|O[A-Za-z])/;
|
|
170
|
+
function escapeKey(sequence) {
|
|
171
|
+
const final = sequence.slice(-1);
|
|
172
|
+
if (final === '~') {
|
|
173
|
+
const code = sequence.slice(2, -1).split(';')[0];
|
|
174
|
+
if (code === '3')
|
|
175
|
+
return { name: 'delete' };
|
|
176
|
+
if (code === '1' || code === '7')
|
|
177
|
+
return { name: 'home' };
|
|
178
|
+
if (code === '4' || code === '8')
|
|
179
|
+
return { name: 'end' };
|
|
180
|
+
// Bracketed-paste markers (200~, 201~) and function keys.
|
|
181
|
+
return { name: 'other' };
|
|
182
|
+
}
|
|
183
|
+
switch (final) {
|
|
184
|
+
case 'A': return { name: 'up' };
|
|
185
|
+
case 'B': return { name: 'down' };
|
|
186
|
+
case 'C': return { name: 'right' };
|
|
187
|
+
case 'D': return { name: 'left' };
|
|
188
|
+
case 'H': return { name: 'home' };
|
|
189
|
+
case 'F': return { name: 'end' };
|
|
190
|
+
case 'Z': return { name: 'tab' };
|
|
191
|
+
default: return { name: 'other' };
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/** Keys in a chunk of raw-mode input; a paste arrives as many `char` keys. */
|
|
195
|
+
export function parseKeys(input) {
|
|
196
|
+
const keys = [];
|
|
197
|
+
for (let index = 0; index < input.length;) {
|
|
198
|
+
if (input[index] === '\u001b') {
|
|
199
|
+
const match = ESCAPE_KEY.exec(input.slice(index));
|
|
200
|
+
keys.push(match ? escapeKey(match[0]) : { name: 'escape' });
|
|
201
|
+
index += match ? match[0].length : 1;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
const char = String.fromCodePoint(input.codePointAt(index));
|
|
205
|
+
index += char.length;
|
|
206
|
+
switch (char) {
|
|
207
|
+
case '\r':
|
|
208
|
+
case '\n':
|
|
209
|
+
keys.push({ name: 'enter' });
|
|
210
|
+
if (char === '\r' && input[index] === '\n')
|
|
211
|
+
index++;
|
|
212
|
+
break;
|
|
213
|
+
case '\u007f':
|
|
214
|
+
case '\b':
|
|
215
|
+
keys.push({ name: 'backspace' });
|
|
216
|
+
break;
|
|
217
|
+
case '\u0003':
|
|
218
|
+
keys.push({ name: 'ctrl-c' });
|
|
219
|
+
break;
|
|
220
|
+
case '\u0004':
|
|
221
|
+
keys.push({ name: 'ctrl-d' });
|
|
222
|
+
break;
|
|
223
|
+
case '\u0015':
|
|
224
|
+
keys.push({ name: 'ctrl-u' });
|
|
225
|
+
break;
|
|
226
|
+
case '\u0017':
|
|
227
|
+
keys.push({ name: 'ctrl-w' });
|
|
228
|
+
break;
|
|
229
|
+
case '\t':
|
|
230
|
+
keys.push({ name: 'tab' });
|
|
231
|
+
break;
|
|
232
|
+
default: keys.push(charWidth(char) === 0 && /[\p{Cc}\p{Cf}]/u.test(char) ? { name: 'other' } : { name: 'char', char });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return keys;
|
|
236
|
+
}
|
|
237
|
+
// ============================================================================
|
|
238
|
+
// The live region
|
|
239
|
+
// ============================================================================
|
|
240
|
+
/**
|
|
241
|
+
* The lines at the bottom of the output that are redrawn in place: each
|
|
242
|
+
* update moves the cursor back to the region's first line, clears to the end
|
|
243
|
+
* of the screen and writes the new lines in a single write. Lines are cut to
|
|
244
|
+
* the terminal width, so each takes one row; after a resize, the previous
|
|
245
|
+
* lines are counted at the new width (terminals rewrap them).
|
|
246
|
+
*/
|
|
247
|
+
export class LiveRegion {
|
|
248
|
+
out;
|
|
249
|
+
ellipsis;
|
|
250
|
+
lines = [];
|
|
251
|
+
constructor(out, ellipsis = '…') {
|
|
252
|
+
this.out = out;
|
|
253
|
+
this.ellipsis = ellipsis;
|
|
254
|
+
}
|
|
255
|
+
/** The terminal width; 80 when it reports none (0 or undefined, as some pseudo-terminals do). */
|
|
256
|
+
get columns() {
|
|
257
|
+
const columns = Math.floor(this.out.columns ?? 0);
|
|
258
|
+
return columns > 0 ? Math.max(2, columns) : 80;
|
|
259
|
+
}
|
|
260
|
+
get rows() {
|
|
261
|
+
const rows = Math.floor(this.out.rows ?? 0);
|
|
262
|
+
return rows > 0 ? Math.max(6, rows) : 24;
|
|
263
|
+
}
|
|
264
|
+
erase() {
|
|
265
|
+
if (this.lines.length === 0)
|
|
266
|
+
return '';
|
|
267
|
+
const rows = this.lines.reduce((sum, line) => sum + Math.max(1, Math.ceil(displayWidth(line) / this.columns)), 0);
|
|
268
|
+
return `\r${rows > 1 ? `\u001b[${rows - 1}A` : ''}\u001b[J`;
|
|
269
|
+
}
|
|
270
|
+
/** Lines that fit: one column short of the width (no pending wrap) and one row short of the height. */
|
|
271
|
+
fit(lines) {
|
|
272
|
+
const width = this.columns - 1;
|
|
273
|
+
const kept = lines.length > this.rows - 1 ? [...lines.slice(0, this.rows - 2), this.ellipsis] : lines;
|
|
274
|
+
return kept.map((line) => truncate(line, width, this.ellipsis));
|
|
275
|
+
}
|
|
276
|
+
draw(lines) {
|
|
277
|
+
const next = this.fit(lines);
|
|
278
|
+
this.out.write(this.erase() + next.join('\n'));
|
|
279
|
+
this.lines = next;
|
|
280
|
+
}
|
|
281
|
+
/** Permanent lines above the region (never cut; long ones wrap), then the region again. */
|
|
282
|
+
commit(lines) {
|
|
283
|
+
const live = this.lines;
|
|
284
|
+
this.out.write(this.erase() + lines.map((line) => `${line}\n`).join('') + live.join('\n'));
|
|
285
|
+
}
|
|
286
|
+
clear() {
|
|
287
|
+
if (this.lines.length === 0)
|
|
288
|
+
return;
|
|
289
|
+
this.out.write(this.erase());
|
|
290
|
+
this.lines = [];
|
|
291
|
+
}
|
|
292
|
+
get height() {
|
|
293
|
+
return this.lines.length;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
const HIDE_CURSOR = '\u001b[?25l';
|
|
297
|
+
const SHOW_CURSOR = '\u001b[?25h';
|
|
298
|
+
export class Tui {
|
|
299
|
+
symbols;
|
|
300
|
+
style;
|
|
301
|
+
features;
|
|
302
|
+
/** What Ctrl-C (or SIGTERM) prints before the process exits. */
|
|
303
|
+
cancelMessage = 'Cancelled.';
|
|
304
|
+
stdin;
|
|
305
|
+
stderr;
|
|
306
|
+
region;
|
|
307
|
+
signals;
|
|
308
|
+
exitProcess;
|
|
309
|
+
frameMs;
|
|
310
|
+
decoder = new StringDecoder('utf8');
|
|
311
|
+
frame = null;
|
|
312
|
+
animated = false;
|
|
313
|
+
timer = null;
|
|
314
|
+
tick = 0;
|
|
315
|
+
keyHandler = null;
|
|
316
|
+
reading = false;
|
|
317
|
+
raw = false;
|
|
318
|
+
suspended = false;
|
|
319
|
+
closed = false;
|
|
320
|
+
interrupted = false;
|
|
321
|
+
constructor(options) {
|
|
322
|
+
this.stdin = options.stdin;
|
|
323
|
+
this.stderr = options.stderr;
|
|
324
|
+
this.features = options.features;
|
|
325
|
+
this.symbols = options.features.unicode ? UNICODE_SYMBOLS : ASCII_SYMBOLS;
|
|
326
|
+
this.style = createStyles(options.features.color);
|
|
327
|
+
this.region = new LiveRegion(options.stderr, this.symbols.ellipsis);
|
|
328
|
+
this.signals = options.signals ?? process;
|
|
329
|
+
this.exitProcess = options.exit ?? ((code) => process.exit(code));
|
|
330
|
+
this.frameMs = options.frameMs ?? 80;
|
|
331
|
+
this.signals.on('SIGINT', this.onSigint);
|
|
332
|
+
this.signals.on('SIGTERM', this.onSigterm);
|
|
333
|
+
this.signals.on('exit', this.onExit);
|
|
334
|
+
this.stderr.on?.('resize', this.onResize);
|
|
335
|
+
this.stderr.write(HIDE_CURSOR);
|
|
336
|
+
this.attachInput();
|
|
337
|
+
}
|
|
338
|
+
/** True once the user cancelled (Ctrl-C or SIGTERM); nothing is drawn after that. */
|
|
339
|
+
get isInterrupted() {
|
|
340
|
+
return this.interrupted;
|
|
341
|
+
}
|
|
342
|
+
get columns() {
|
|
343
|
+
return this.region.columns;
|
|
344
|
+
}
|
|
345
|
+
// --------------------------------------------------------------------------
|
|
346
|
+
// Output
|
|
347
|
+
// --------------------------------------------------------------------------
|
|
348
|
+
/** Permanent lines above the live frame. */
|
|
349
|
+
print(...lines) {
|
|
350
|
+
if (this.closed)
|
|
351
|
+
return;
|
|
352
|
+
if (this.suspended)
|
|
353
|
+
this.stderr.write(lines.map((line) => `${line}\n`).join(''));
|
|
354
|
+
else
|
|
355
|
+
this.region.commit(lines);
|
|
356
|
+
}
|
|
357
|
+
/** `│` */
|
|
358
|
+
rail() {
|
|
359
|
+
this.print(this.style.dim(this.symbols.rail));
|
|
360
|
+
}
|
|
361
|
+
/** `◇ title detail` */
|
|
362
|
+
step(title, detail) {
|
|
363
|
+
this.print(`${this.style.green(this.symbols.done)} ${title}${detail ? ` ${detail}` : ''}`);
|
|
364
|
+
}
|
|
365
|
+
/** `◇ title › value` (an answered question). */
|
|
366
|
+
answered(title, value) {
|
|
367
|
+
this.print(`${this.style.green(this.symbols.done)} ${title} ${this.style.dim(this.symbols.pointer)} ${value}`);
|
|
368
|
+
}
|
|
369
|
+
/** `│ text`, wrapped at word boundaries (`text` is plain). */
|
|
370
|
+
info(text) {
|
|
371
|
+
this.print(...this.wrap(text, 3).map((part) => `${this.style.dim(this.symbols.rail)} ${part}`));
|
|
372
|
+
}
|
|
373
|
+
/** `▲ text` */
|
|
374
|
+
warn(text) {
|
|
375
|
+
this.print(`${this.style.yellow(this.symbols.warning)} ${text}`);
|
|
376
|
+
}
|
|
377
|
+
/** `■ text`, then dim detail lines under it. */
|
|
378
|
+
error(text, ...details) {
|
|
379
|
+
this.print(`${this.style.red(this.symbols.error)} ${text}`, ...details.flatMap((line) => this.wrap(line, 3).map((part) => ` ${this.style.dim(part)}`)));
|
|
380
|
+
}
|
|
381
|
+
/** `│ text` (dim): a note inside the current step, wrapped at word boundaries. */
|
|
382
|
+
note(text) {
|
|
383
|
+
this.print(...this.wrap(text, 3).map((part) => `${this.style.dim(this.symbols.rail)} ${this.style.dim(part)}`));
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* A command the user may copy, indented and on a line of its own: never
|
|
387
|
+
* wrapped, cut or prefixed (a long one is soft-wrapped by the terminal and
|
|
388
|
+
* still copies as one line).
|
|
389
|
+
*/
|
|
390
|
+
command(text) {
|
|
391
|
+
this.print(` ${text}`);
|
|
392
|
+
}
|
|
393
|
+
/** Dim lines under the end of the timeline (after `└`), indented and wrapped. */
|
|
394
|
+
footnote(text) {
|
|
395
|
+
this.print(...this.wrap(text, 3).map((part) => ` ${this.style.dim(part)}`));
|
|
396
|
+
}
|
|
397
|
+
/** Plain text in lines that fit after an `indent`-column prefix (a longer word, such as a path, stays whole). */
|
|
398
|
+
wrap(text, indent) {
|
|
399
|
+
return wrapWords(text, Math.max(20, this.columns - 1 - indent));
|
|
400
|
+
}
|
|
401
|
+
/** The current spinner frame. */
|
|
402
|
+
get spin() {
|
|
403
|
+
return this.style.cyan(this.symbols.spinner[this.tick % this.symbols.spinner.length]);
|
|
404
|
+
}
|
|
405
|
+
/** `███░░░` for a ratio between 0 and 1. */
|
|
406
|
+
bar(ratio, width) {
|
|
407
|
+
const filled = Math.round(Math.max(0, Math.min(1, ratio)) * width);
|
|
408
|
+
return this.style.cyan(this.symbols.barFull.repeat(filled)) + this.style.dim(this.symbols.barEmpty.repeat(width - filled));
|
|
409
|
+
}
|
|
410
|
+
/** The items of a summary line: `a · b · c`, or `a - b - c` in ASCII. */
|
|
411
|
+
join(parts) {
|
|
412
|
+
return parts.join(` ${this.symbols.separator} `);
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Text written elsewhere with the Unicode symbols (an error message with a
|
|
416
|
+
* masked ID, a warning) in this session's: on an ASCII-only console, `…`
|
|
417
|
+
* becomes `...` and ` · ` becomes ` - `.
|
|
418
|
+
*/
|
|
419
|
+
symbolText(text) {
|
|
420
|
+
if (this.features.unicode)
|
|
421
|
+
return text;
|
|
422
|
+
return text.replace(/…/g, this.symbols.ellipsis).replace(/ · /g, ` ${this.symbols.separator} `);
|
|
423
|
+
}
|
|
424
|
+
/** An OSC 8 link where the terminal supports it, else the text. The URL must be printable ASCII. */
|
|
425
|
+
link(url, text = url) {
|
|
426
|
+
return this.features.hyperlinks ? `\u001b]8;;${url}\u001b\\${text}\u001b]8;;\u001b\\` : text;
|
|
427
|
+
}
|
|
428
|
+
/** The live frame, replacing the previous one; animated frames are redrawn every frame (spinners). */
|
|
429
|
+
show(render, options = {}) {
|
|
430
|
+
this.frame = render;
|
|
431
|
+
this.animated = Boolean(options.animated);
|
|
432
|
+
this.syncTimer();
|
|
433
|
+
this.draw();
|
|
434
|
+
}
|
|
435
|
+
clearFrame() {
|
|
436
|
+
this.frame = null;
|
|
437
|
+
this.animated = false;
|
|
438
|
+
this.syncTimer();
|
|
439
|
+
if (!this.closed && !this.suspended)
|
|
440
|
+
this.region.clear();
|
|
441
|
+
}
|
|
442
|
+
/** Redraw the live frame now (after its state changed). */
|
|
443
|
+
draw() {
|
|
444
|
+
if (this.closed || this.suspended)
|
|
445
|
+
return;
|
|
446
|
+
if (this.frame)
|
|
447
|
+
this.region.draw(this.frame());
|
|
448
|
+
else
|
|
449
|
+
this.region.clear();
|
|
450
|
+
}
|
|
451
|
+
/** `◒ label` until stopped. */
|
|
452
|
+
spinner(label) {
|
|
453
|
+
let text = label;
|
|
454
|
+
let stopped = false;
|
|
455
|
+
const render = () => [`${this.spin} ${text}`];
|
|
456
|
+
this.show(render, { animated: true });
|
|
457
|
+
return {
|
|
458
|
+
update: (next) => {
|
|
459
|
+
text = next;
|
|
460
|
+
if (!stopped && this.frame === render)
|
|
461
|
+
this.draw();
|
|
462
|
+
},
|
|
463
|
+
stop: () => {
|
|
464
|
+
if (stopped)
|
|
465
|
+
return;
|
|
466
|
+
stopped = true;
|
|
467
|
+
if (this.frame === render)
|
|
468
|
+
this.clearFrame();
|
|
469
|
+
},
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
/** `work` with a spinner. */
|
|
473
|
+
async busy(label, work) {
|
|
474
|
+
const spinner = this.spinner(label);
|
|
475
|
+
try {
|
|
476
|
+
return await work();
|
|
477
|
+
}
|
|
478
|
+
finally {
|
|
479
|
+
spinner.stop();
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
syncTimer() {
|
|
483
|
+
const wanted = this.animated && !this.suspended && !this.closed;
|
|
484
|
+
if (wanted && !this.timer) {
|
|
485
|
+
this.timer = setInterval(() => {
|
|
486
|
+
this.tick++;
|
|
487
|
+
this.draw();
|
|
488
|
+
}, this.frameMs);
|
|
489
|
+
this.timer.unref?.();
|
|
490
|
+
}
|
|
491
|
+
else if (!wanted && this.timer) {
|
|
492
|
+
clearInterval(this.timer);
|
|
493
|
+
this.timer = null;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
// --------------------------------------------------------------------------
|
|
497
|
+
// Inputs
|
|
498
|
+
// --------------------------------------------------------------------------
|
|
499
|
+
/** Run a prompt until a key ends it; its frame is cleared afterwards. Ctrl-C never ends it (it cancels the run). */
|
|
500
|
+
prompt(view) {
|
|
501
|
+
if (this.closed)
|
|
502
|
+
return new Promise(() => undefined);
|
|
503
|
+
return new Promise((resolve) => {
|
|
504
|
+
this.keyHandler = (key) => {
|
|
505
|
+
const outcome = view.key(key);
|
|
506
|
+
if (!outcome) {
|
|
507
|
+
this.draw();
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
this.keyHandler = null;
|
|
511
|
+
this.clearFrame();
|
|
512
|
+
resolve(outcome.value);
|
|
513
|
+
};
|
|
514
|
+
this.show(() => view.render());
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
/** The fake cursor of inputs (the real one stays hidden). */
|
|
518
|
+
get cursor() {
|
|
519
|
+
return this.features.color ? this.style.inverse(' ') : '_';
|
|
520
|
+
}
|
|
521
|
+
head(title, value) {
|
|
522
|
+
return `${this.style.cyan(this.symbols.active)} ${title} ${this.style.dim(this.symbols.pointer)} ${value}`;
|
|
523
|
+
}
|
|
524
|
+
/** Columns an input's first line has for its title and value together (the frame's width less `◆ ` and ` › `). */
|
|
525
|
+
get headRoom() {
|
|
526
|
+
return this.columns - 1 - displayWidth(this.head('', ''));
|
|
527
|
+
}
|
|
528
|
+
/** A plain title cut (start and end kept) to at most `width` columns, so the value after it stays on screen. */
|
|
529
|
+
fitTitle(title, width) {
|
|
530
|
+
return truncateMiddle(title, Math.max(1, width), this.symbols.ellipsis);
|
|
531
|
+
}
|
|
532
|
+
noticeLines(notice) {
|
|
533
|
+
return notice ? [`${this.style.yellow(this.symbols.rail)} ${this.style.yellow(notice)}`] : [];
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* A one-line text input: printable characters, Backspace, Ctrl-U (clear) and
|
|
537
|
+
* Ctrl-W (last word). Null on Ctrl-D with no text (the input ended). On a
|
|
538
|
+
* narrow terminal the (plain) title is cut before the value, down to a third
|
|
539
|
+
* of the line; a value wider than the rest shows its end.
|
|
540
|
+
*/
|
|
541
|
+
text(options) {
|
|
542
|
+
const max = options.maxLength ?? 256;
|
|
543
|
+
let value = '';
|
|
544
|
+
return this.prompt({
|
|
545
|
+
render: () => {
|
|
546
|
+
const available = this.headRoom;
|
|
547
|
+
const wanted = displayWidth(value) + 2;
|
|
548
|
+
const titleWidth = Math.min(displayWidth(options.title), Math.max(available - wanted, Math.floor(available / 3)));
|
|
549
|
+
const title = this.fitTitle(options.title, titleWidth);
|
|
550
|
+
const room = Math.max(8, available - displayWidth(title) - 2);
|
|
551
|
+
const shown = displayWidth(value) > room ? `${this.symbols.ellipsis}${tail(value, room - displayWidth(this.symbols.ellipsis))}` : value;
|
|
552
|
+
return [this.head(title, `${shown}${this.cursor}`), ...this.noticeLines(options.notice)];
|
|
553
|
+
},
|
|
554
|
+
key: (key) => {
|
|
555
|
+
switch (key.name) {
|
|
556
|
+
case 'enter': return { value };
|
|
557
|
+
case 'ctrl-d': return value === '' ? { value: null } : undefined;
|
|
558
|
+
case 'backspace':
|
|
559
|
+
value = [...value].slice(0, -1).join('');
|
|
560
|
+
return undefined;
|
|
561
|
+
case 'ctrl-u':
|
|
562
|
+
value = '';
|
|
563
|
+
return undefined;
|
|
564
|
+
case 'ctrl-w':
|
|
565
|
+
value = value.replace(/\S*\s*$/u, '');
|
|
566
|
+
return undefined;
|
|
567
|
+
case 'char':
|
|
568
|
+
if (charWidth(key.char) > 0 && [...value].length < max)
|
|
569
|
+
value += key.char;
|
|
570
|
+
return undefined;
|
|
571
|
+
default: return undefined;
|
|
572
|
+
}
|
|
573
|
+
},
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* A numeric code input: digits are shown as typed (spaces and dashes, typed
|
|
578
|
+
* or pasted, are skipped) and the input ends at the last digit. Enter with
|
|
579
|
+
* fewer digits keeps it open. Null on Ctrl-D (the input ended). On a narrow
|
|
580
|
+
* terminal the (plain) title is cut, never the digits.
|
|
581
|
+
*/
|
|
582
|
+
code(options) {
|
|
583
|
+
let digits = '';
|
|
584
|
+
let short = false;
|
|
585
|
+
return this.prompt({
|
|
586
|
+
render: () => {
|
|
587
|
+
const rest = this.style.dim(this.symbols.placeholder.repeat(Math.max(0, options.length - digits.length - 1)));
|
|
588
|
+
const value = `${digits}${digits.length < options.length ? this.cursor : ''}${rest}`;
|
|
589
|
+
return [
|
|
590
|
+
this.head(this.fitTitle(options.title, this.headRoom - displayWidth(value)), value),
|
|
591
|
+
...(options.hint ? [`${this.style.dim(this.symbols.rail)} ${this.style.dim(options.hint)}`] : []),
|
|
592
|
+
...this.noticeLines(short ? `Enter all ${options.length} digits.` : options.notice),
|
|
593
|
+
];
|
|
594
|
+
},
|
|
595
|
+
key: (key) => {
|
|
596
|
+
if (key.name === 'ctrl-d')
|
|
597
|
+
return { value: null };
|
|
598
|
+
if (key.name === 'backspace')
|
|
599
|
+
digits = digits.slice(0, -1);
|
|
600
|
+
else if (key.name === 'ctrl-u')
|
|
601
|
+
digits = '';
|
|
602
|
+
else if (key.name === 'enter')
|
|
603
|
+
short = digits.length < options.length;
|
|
604
|
+
else if (key.name === 'char' && /^\d$/.test(key.char)) {
|
|
605
|
+
digits += key.char;
|
|
606
|
+
short = false;
|
|
607
|
+
}
|
|
608
|
+
return digits.length === options.length ? { value: digits } : undefined;
|
|
609
|
+
},
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* An arrow-key list (↑/↓ or j/k, Home/End, Enter). Null on Ctrl-D. Labels
|
|
614
|
+
* are plain text, padded to one column; on a narrow terminal each is cut in
|
|
615
|
+
* the middle so its detail (a masked ID) always stays on screen.
|
|
616
|
+
*/
|
|
617
|
+
select(options) {
|
|
618
|
+
const { items } = options;
|
|
619
|
+
let index = Math.max(0, Math.min(items.length - 1, options.initial ?? 0));
|
|
620
|
+
let top = 0;
|
|
621
|
+
const widest = Math.max(0, ...items.map((item) => displayWidth(item.label)));
|
|
622
|
+
const detailWidth = Math.max(0, ...items.map((item) => (item.detail ? displayWidth(item.detail) + 2 : 0)));
|
|
623
|
+
return this.prompt({
|
|
624
|
+
render: () => {
|
|
625
|
+
const visible = Math.max(3, Math.min(items.length, this.region.rows - 5, 10));
|
|
626
|
+
if (index < top)
|
|
627
|
+
top = index;
|
|
628
|
+
if (index >= top + visible)
|
|
629
|
+
top = index - visible + 1;
|
|
630
|
+
const rail = this.style.dim(this.symbols.rail);
|
|
631
|
+
const prefix = displayWidth(`${this.symbols.rail} ${this.symbols.radioOn} `);
|
|
632
|
+
const labelWidth = Math.min(widest, Math.max(4, this.columns - 1 - prefix - detailWidth));
|
|
633
|
+
const rows = items.slice(top, top + visible).map((item, offset) => {
|
|
634
|
+
const active = top + offset === index;
|
|
635
|
+
const mark = active ? this.style.green(this.symbols.radioOn) : this.style.dim(this.symbols.radioOff);
|
|
636
|
+
const fitted = padEnd(truncateMiddle(item.label, labelWidth, this.symbols.ellipsis), labelWidth);
|
|
637
|
+
const label = active ? this.style.bold(fitted) : fitted;
|
|
638
|
+
return `${rail} ${mark} ${label}${item.detail ? ` ${this.style.dim(item.detail)}` : ''}`;
|
|
639
|
+
});
|
|
640
|
+
const above = top > 0 ? [`${rail} ${this.style.dim(`${this.symbols.up} ${top} more`)}`] : [];
|
|
641
|
+
const below = top + visible < items.length ? [`${rail} ${this.style.dim(`${this.symbols.down} ${items.length - top - visible} more`)}`] : [];
|
|
642
|
+
return [`${this.style.cyan(this.symbols.active)} ${options.title}${options.hint ? ` ${this.style.dim(options.hint)}` : ''}`, ...above, ...rows, ...below];
|
|
643
|
+
},
|
|
644
|
+
key: (key) => {
|
|
645
|
+
if (key.name === 'enter')
|
|
646
|
+
return { value: items[index].value };
|
|
647
|
+
if (key.name === 'ctrl-d')
|
|
648
|
+
return { value: null };
|
|
649
|
+
if (key.name === 'up' || (key.name === 'char' && key.char === 'k'))
|
|
650
|
+
index = (index - 1 + items.length) % items.length;
|
|
651
|
+
else if (key.name === 'down' || key.name === 'tab' || (key.name === 'char' && key.char === 'j'))
|
|
652
|
+
index = (index + 1) % items.length;
|
|
653
|
+
else if (key.name === 'home')
|
|
654
|
+
index = 0;
|
|
655
|
+
else if (key.name === 'end')
|
|
656
|
+
index = items.length - 1;
|
|
657
|
+
return undefined;
|
|
658
|
+
},
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
/** Yes / No: ←/→ (or Tab, h/l) move, y or n answers at once, Enter answers the highlighted choice. Null on Ctrl-D. */
|
|
662
|
+
confirm(options) {
|
|
663
|
+
let yes = options.initial;
|
|
664
|
+
const choice = (label, on) => {
|
|
665
|
+
if (!this.features.color)
|
|
666
|
+
return on ? `[${label}]` : label;
|
|
667
|
+
return on ? this.style.underline(this.style.cyan(label)) : this.style.dim(label);
|
|
668
|
+
};
|
|
669
|
+
return this.prompt({
|
|
670
|
+
render: () => [
|
|
671
|
+
`${this.style.cyan(this.symbols.active)} ${options.title} ${this.style.dim(this.symbols.pointer)} ${choice('Yes', yes)} ${this.style.dim('/')} ${choice('No', !yes)}`,
|
|
672
|
+
...(options.detail ? [`${this.style.dim(this.symbols.rail)} ${this.style.dim(options.detail)}`] : []),
|
|
673
|
+
],
|
|
674
|
+
key: (key) => {
|
|
675
|
+
if (key.name === 'enter')
|
|
676
|
+
return { value: yes };
|
|
677
|
+
if (key.name === 'ctrl-d')
|
|
678
|
+
return { value: null };
|
|
679
|
+
if (key.name === 'char' && /^[yY]$/.test(key.char))
|
|
680
|
+
return { value: true };
|
|
681
|
+
if (key.name === 'char' && /^[nN]$/.test(key.char))
|
|
682
|
+
return { value: false };
|
|
683
|
+
if (['left', 'right', 'tab'].includes(key.name) || (key.name === 'char' && /^[hl]$/.test(key.char)))
|
|
684
|
+
yes = !yes;
|
|
685
|
+
return undefined;
|
|
686
|
+
},
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
// --------------------------------------------------------------------------
|
|
690
|
+
// The terminal
|
|
691
|
+
// --------------------------------------------------------------------------
|
|
692
|
+
onData = (chunk) => {
|
|
693
|
+
const text = typeof chunk === 'string' ? chunk : this.decoder.write(chunk);
|
|
694
|
+
for (const key of parseKeys(text)) {
|
|
695
|
+
if (key.name === 'ctrl-c') {
|
|
696
|
+
this.interrupt();
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
// Keys typed while nothing asks are dropped (and were never echoed).
|
|
700
|
+
this.keyHandler?.(key);
|
|
701
|
+
}
|
|
702
|
+
};
|
|
703
|
+
/** Ctrl-C in raw mode sends no signal: raise SIGINT for every listener, this session's included. */
|
|
704
|
+
interrupt() {
|
|
705
|
+
this.signals.emit('SIGINT', 'SIGINT');
|
|
706
|
+
}
|
|
707
|
+
setRaw(on) {
|
|
708
|
+
if (this.raw === on)
|
|
709
|
+
return;
|
|
710
|
+
this.raw = on;
|
|
711
|
+
try {
|
|
712
|
+
this.stdin.setRawMode?.(on);
|
|
713
|
+
}
|
|
714
|
+
catch {
|
|
715
|
+
// The terminal is already gone.
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
attachInput() {
|
|
719
|
+
if (this.reading)
|
|
720
|
+
return;
|
|
721
|
+
this.reading = true;
|
|
722
|
+
this.setRaw(true);
|
|
723
|
+
this.stdin.on('data', this.onData);
|
|
724
|
+
this.stdin.resume();
|
|
725
|
+
}
|
|
726
|
+
detachInput() {
|
|
727
|
+
if (!this.reading)
|
|
728
|
+
return;
|
|
729
|
+
this.reading = false;
|
|
730
|
+
this.stdin.removeListener('data', this.onData);
|
|
731
|
+
this.stdin.pause();
|
|
732
|
+
this.setRaw(false);
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* Hand the terminal to another program (cf's interactive sign-in): the
|
|
736
|
+
* frame is cleared, raw mode is off, input is released and the cursor shown.
|
|
737
|
+
*/
|
|
738
|
+
suspend() {
|
|
739
|
+
if (this.closed || this.suspended)
|
|
740
|
+
return;
|
|
741
|
+
this.region.clear();
|
|
742
|
+
this.suspended = true;
|
|
743
|
+
this.syncTimer();
|
|
744
|
+
this.detachInput();
|
|
745
|
+
this.stderr.write(SHOW_CURSOR);
|
|
746
|
+
}
|
|
747
|
+
resume() {
|
|
748
|
+
if (this.closed || !this.suspended)
|
|
749
|
+
return;
|
|
750
|
+
this.suspended = false;
|
|
751
|
+
this.stderr.write(HIDE_CURSOR);
|
|
752
|
+
this.attachInput();
|
|
753
|
+
this.syncTimer();
|
|
754
|
+
this.draw();
|
|
755
|
+
}
|
|
756
|
+
/** `work` with the terminal handed over (see `suspend`). */
|
|
757
|
+
async handOver(work) {
|
|
758
|
+
this.suspend();
|
|
759
|
+
try {
|
|
760
|
+
return await work();
|
|
761
|
+
}
|
|
762
|
+
finally {
|
|
763
|
+
this.resume();
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
onResize = () => {
|
|
767
|
+
this.draw();
|
|
768
|
+
};
|
|
769
|
+
/** Raw mode off, the frame cleared, the cursor shown; nothing more is drawn. */
|
|
770
|
+
restore() {
|
|
771
|
+
if (this.closed)
|
|
772
|
+
return;
|
|
773
|
+
this.closed = true;
|
|
774
|
+
if (this.timer)
|
|
775
|
+
clearInterval(this.timer);
|
|
776
|
+
this.timer = null;
|
|
777
|
+
this.keyHandler = null;
|
|
778
|
+
if (!this.suspended)
|
|
779
|
+
this.region.clear();
|
|
780
|
+
this.detachInput();
|
|
781
|
+
this.stderr.write(SHOW_CURSOR);
|
|
782
|
+
this.stderr.removeListener?.('resize', this.onResize);
|
|
783
|
+
}
|
|
784
|
+
detachSignals() {
|
|
785
|
+
this.signals.removeListener('SIGINT', this.onSigint);
|
|
786
|
+
this.signals.removeListener('SIGTERM', this.onSigterm);
|
|
787
|
+
this.signals.removeListener('exit', this.onExit);
|
|
788
|
+
}
|
|
789
|
+
/** Print the cancel message; exit with `code` unless another listener for `signal` will (it exits 130 after cleaning up). */
|
|
790
|
+
cancel(signal, code) {
|
|
791
|
+
if (this.interrupted || this.closed)
|
|
792
|
+
return;
|
|
793
|
+
this.interrupted = true;
|
|
794
|
+
this.restore();
|
|
795
|
+
this.stderr.write(`${this.style.red(this.symbols.error)} ${this.cancelMessage}\n`);
|
|
796
|
+
this.detachSignals();
|
|
797
|
+
if (this.signals.listenerCount(signal) === 0)
|
|
798
|
+
this.exitProcess(code);
|
|
799
|
+
}
|
|
800
|
+
onSigint = () => this.cancel('SIGINT', 130);
|
|
801
|
+
onSigterm = () => this.cancel('SIGTERM', 143);
|
|
802
|
+
onExit = () => this.restore();
|
|
803
|
+
/** End the session: the terminal is restored and the listeners removed. Safe to call twice. */
|
|
804
|
+
close() {
|
|
805
|
+
this.restore();
|
|
806
|
+
this.detachSignals();
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
const renderedErrors = new WeakSet();
|
|
810
|
+
/** The terminal UI has shown this error (■ and its hint); `runCli` does not print it again. */
|
|
811
|
+
export function markRendered(error) {
|
|
812
|
+
renderedErrors.add(error);
|
|
813
|
+
}
|
|
814
|
+
export function wasRendered(error) {
|
|
815
|
+
return typeof error === 'object' && error !== null && renderedErrors.has(error);
|
|
816
|
+
}
|
|
817
|
+
/**
|
|
818
|
+
* Words in lines of at most `width` columns; a longer word gets a line of its
|
|
819
|
+
* own. Leading spaces (an indented line) are kept on every line.
|
|
820
|
+
*/
|
|
821
|
+
export function wrapWords(text, width) {
|
|
822
|
+
const indent = /^ */.exec(text)[0];
|
|
823
|
+
const lines = [];
|
|
824
|
+
let line = '';
|
|
825
|
+
for (const word of text.slice(indent.length).split(' ')) {
|
|
826
|
+
const next = line === '' ? word : `${line} ${word}`;
|
|
827
|
+
if (line !== '' && displayWidth(indent + next) > width) {
|
|
828
|
+
lines.push(indent + line);
|
|
829
|
+
line = word;
|
|
830
|
+
}
|
|
831
|
+
else {
|
|
832
|
+
line = next;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
lines.push(indent + line);
|
|
836
|
+
return lines;
|
|
837
|
+
}
|
|
838
|
+
/** The last `width` columns of `text`. */
|
|
839
|
+
function tail(text, width) {
|
|
840
|
+
const chars = [...text];
|
|
841
|
+
let used = 0;
|
|
842
|
+
let start = chars.length;
|
|
843
|
+
while (start > 0 && used + charWidth(chars[start - 1]) <= width)
|
|
844
|
+
used += charWidth(chars[--start]);
|
|
845
|
+
return chars.slice(start).join('');
|
|
846
|
+
}
|
|
847
|
+
//# sourceMappingURL=tui.js.map
|