@phantomcodes/cli 1.2.3 → 1.2.5

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.
@@ -34,6 +34,6 @@ export function die(msg, code = 'cli_error', exitCode = 1) {
34
34
  throw new CliError(msg, code, exitCode);
35
35
  }
36
36
  /** How to get a key, for every message about a missing one. */
37
- export const GET_A_KEY = 'Set PHANTOM_API_KEY, or run: phantom login to connect a workspace';
37
+ export const GET_A_KEY = 'Run phantom login to sign in, or set PHANTOM_API_KEY.';
38
38
  /** The message for a command that needs a key and has none. */
39
- export const NO_KEY = `No API key. ${GET_A_KEY}`;
39
+ export const NO_KEY = `No Phantom key yet. ${GET_A_KEY}`;
@@ -1,26 +1,59 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { PhantomApiError, CliError, die } from "./errors.mjs";
3
+ import { colorOn, paint } from "./style.mjs";
4
+ import { Cancelled, canPick } from "./pick.mjs";
5
+ /**
6
+ * Where a picker draws and reads, when this run can show one: a person at a
7
+ * terminal (or keys a test scripted), not CI. Null means use flags and the
8
+ * plain prompts instead.
9
+ */
10
+ export function pickIo(input) {
11
+ const { io, env } = input;
12
+ if (!(io.keys ? io.isTTY : canPick(env, io.isTTY)))
13
+ return null;
14
+ // A command started from the menu goes back to it on Esc; one typed on its own quits.
15
+ return { write: io.stderr, env, keys: io.keys, esc: io.fromMenu ? 'back' : 'cancel', columns: process.stderr.columns };
16
+ }
17
+ /**
18
+ * Whether a command should draw its screen rather than print JSON: with
19
+ * `--table`, or when stdout is a terminal, unless `--json` asks for JSON.
20
+ * Scripts and agents read stdout through a pipe, so they keep getting JSON.
21
+ */
22
+ export function showsScreen(input) {
23
+ return input.table || (Boolean(input.io.stdoutIsTTY) && !input.flags['json']);
24
+ }
25
+ /**
26
+ * A phantom command inside a sentence: `phantom`, its name, a second word for
27
+ * the commands that take one (`phantom key show`), then any flags. For
28
+ * example "Run phantom setup to sign in" matches `phantom setup` only.
29
+ */
30
+ const COMMAND_IN_TEXT = /\bphantom [a-z][a-z-]*(?:(?<=(?:key|keys|workspace|plan|route|route rule)) [a-z][a-z-]*)?(?: --?[a-z][a-z-]*(?: <[^>]+>)?)*/g;
3
31
  /**
4
32
  * Prints an error to stderr as one line of JSON, `{"error":{...}}`, and
5
33
  * returns the exit code: 2 for a key the API rejected (401 or 403), the
6
34
  * {@link CliError}'s own code, or 1 for anything else.
7
35
  */
8
- export function handleError(err, stderr = (s) => process.stderr.write(s)) {
9
- if (err instanceof PhantomApiError) {
10
- stderr(JSON.stringify({
11
- error: { status: err.status, code: err.code, message: err.message },
12
- }) + '\n');
13
- return err.status === 401 || err.status === 403 ? 2 : 1;
36
+ export function handleError(err, stderr = (s) => process.stderr.write(s), opts = {}) {
37
+ // Ctrl+C in a picker: stop, with the code shells use for it (128 + SIGINT), and no error to read.
38
+ if (err instanceof Cancelled)
39
+ return 130;
40
+ const api = err instanceof PhantomApiError ? err : null;
41
+ const code = api ? api.code : err instanceof CliError ? err.code : 'unknown';
42
+ const message = err instanceof Error ? err.message : String(err);
43
+ if (opts.human) {
44
+ // A person at a terminal reads a sentence; the code stays for support, dimmed.
45
+ const p = paint(opts.env ?? {}, colorOn(opts.env ?? {}, true));
46
+ const lit = message.replace(COMMAND_IN_TEXT, (cmd) => p.cyan(cmd));
47
+ // The code only when it says more than "an error": HTTP 401, invalid_key helps support; cli_error doesn't.
48
+ const detail = api ? `HTTP ${api.status}, ${code}` : code === 'cli_error' || code === 'unknown' ? '' : code;
49
+ stderr(`\n ${p.red('✗')} ${lit}\n${detail ? p.dim(` ${detail}`) + '\n' : ''}\n`);
14
50
  }
15
- if (err instanceof CliError) {
16
- stderr(JSON.stringify({
17
- error: { code: err.code, message: err.message },
18
- }) + '\n');
19
- return err.exitCode;
51
+ else {
52
+ stderr(JSON.stringify({ error: { ...(api ? { status: api.status } : {}), code, message } }) + '\n');
20
53
  }
21
- const msg = err instanceof Error ? err.message : String(err);
22
- stderr(JSON.stringify({ error: { code: 'unknown', message: msg } }) + '\n');
23
- return 1;
54
+ if (api)
55
+ return api.status === 401 || api.status === 403 ? 2 : 1;
56
+ return err instanceof CliError ? err.exitCode : 1;
24
57
  }
25
58
  /**
26
59
  * Reads `--name value`, `--name=value` and bare `--name` (true) from the
@@ -0,0 +1,156 @@
1
+ import { colorOn } from "./style.mjs";
2
+ import { GHOST, WORDMARK } from "./brand.mjs";
3
+ /** Whether to animate: color is on for that stream (a terminal, no NO_COLOR), it isn't CI, and PHANTOM_NO_MOTION isn't set. */
4
+ export function motionOn(env, isTTY) {
5
+ if (env.PHANTOM_NO_MOTION && env.PHANTOM_NO_MOTION !== '0')
6
+ return false;
7
+ if (env.CI && env.CI !== 'false' && env.CI !== '0')
8
+ return false;
9
+ // FORCE_COLOR can color a pipe, but cursor moves only make sense on a real terminal.
10
+ return colorOn(env, isTTY) && Boolean(isTTY);
11
+ }
12
+ /** Fast at first, then easing to a stop: the curve for things that arrive. */
13
+ export const easeOut = (t) => 1 - (1 - Math.min(1, Math.max(0, t))) ** 3;
14
+ /**
15
+ * Lines appearing top to bottom: frame k shows the first k lines and keeps the
16
+ * rest blank, so the block's height never changes. `delay(line)` is how long
17
+ * the line waits before it appears; 0 lets it come with the one before.
18
+ */
19
+ export function reveal(lines, delay) {
20
+ const book = { frames: [], delays: [] };
21
+ lines.forEach((line, k) => {
22
+ const wait = delay(line, k);
23
+ const frame = lines.map((l, j) => (j <= k ? l : ''));
24
+ // A line with no wait joins the frame before it instead of making its own.
25
+ if (book.frames.length && wait === 0)
26
+ book.frames[book.frames.length - 1] = frame;
27
+ else {
28
+ book.frames.push(frame);
29
+ book.delays.push(book.frames.length === 1 ? 0 : wait);
30
+ }
31
+ });
32
+ return book;
33
+ }
34
+ /**
35
+ * Every bar in `lines` (a run of ━ followed by its ─ track) drawn at
36
+ * `progress` of its length, 0 to 1. The track grows to fill what the bar gives
37
+ * up, so each line keeps its width.
38
+ */
39
+ export function growBars(lines, progress) {
40
+ return lines.map((line) => line.replace(/(━+)((?:\x1b\[[0-9;]*m)*)(─*)/g, (_all, fill, codes, track) => {
41
+ const shown = Math.round(fill.length * progress);
42
+ return '━'.repeat(shown) + codes + '─'.repeat(fill.length - shown + track.length);
43
+ }));
44
+ }
45
+ /** Bars growing from nothing to their length over `ms`, easing out. */
46
+ export function barsGrowing(lines, ms = 250, steps = 10) {
47
+ const frames = Array.from({ length: steps + 1 }, (_, k) => growBars(lines, easeOut(k / steps)));
48
+ return { frames, delays: frames.map((_, k) => (k === 0 ? 0 : ms / steps)) };
49
+ }
50
+ /** The ghost's eye row with the eyes closed: the two gaps filled with a low half block, like lids. */
51
+ export const CLOSED_EYES = GHOST[1][0].replace(/ (?=█)/, '▄').replace(/ $/, '▄');
52
+ /**
53
+ * The brand header drawing in, for a screen whose lines start with it
54
+ * (setup): PHANTOM appears letter by letter, the rest of the screen follows
55
+ * row by row, then the ghost blinks once.
56
+ */
57
+ export function brandIntro(lines, opts = {}) {
58
+ const { letterMs = 20, rowMs = 18, blinkMs = 90 } = opts;
59
+ const wordRows = lines.map((l, k) => [k, WORDMARK.findIndex((w) => l.includes(w))]).filter(([, r]) => r >= 0);
60
+ const eyeRow = lines.findIndex((l) => l.includes(GHOST[1][0]));
61
+ const headerEnd = Math.max(eyeRow, ...wordRows.map(([k]) => k)) + 2;
62
+ const book = { frames: [], delays: [] };
63
+ const push = (frame, wait) => (book.frames.push(frame), book.delays.push(book.frames.length === 1 ? 0 : wait));
64
+ // 1. The ghost, then PHANTOM letter by letter beside it.
65
+ const letterCount = 7;
66
+ for (let n = 0; n <= letterCount; n++) {
67
+ push(lines.map((l, k) => {
68
+ if (k >= headerEnd)
69
+ return '';
70
+ const hit = wordRows.find(([row]) => row === k);
71
+ if (!hit)
72
+ return l;
73
+ return l.replace(WORDMARK[hit[1]], lettersOf(hit[1], n));
74
+ }), letterMs);
75
+ }
76
+ // 2. The rest of the screen, a row at a time.
77
+ for (let k = headerEnd; k < lines.length; k++)
78
+ push(lines.map((l, j) => (j <= k ? l : '')), lines[k] ? rowMs : 0);
79
+ // 3. One blink.
80
+ if (eyeRow >= 0) {
81
+ push(lines.map((l, k) => (k === eyeRow ? l.replace(GHOST[1][0], CLOSED_EYES) : l)), blinkMs);
82
+ push(lines, blinkMs);
83
+ }
84
+ return book;
85
+ }
86
+ /** Row `row` of PHANTOM with its first `n` letters drawn and the rest left as spaces. */
87
+ function lettersOf(row, n) {
88
+ const full = WORDMARK[row];
89
+ const cut = LETTER_ENDS[n];
90
+ return full.slice(0, cut).padEnd(full.length);
91
+ }
92
+ /** Where each letter of PHANTOM ends in a wordmark row (the rows share widths): 0 letters, 1 letter and so on. */
93
+ const LETTER_ENDS = (() => {
94
+ const widths = [4, 4, 4, 4, 3, 4, 5];
95
+ const ends = [0];
96
+ widths.forEach((w, k) => ends.push(ends[k] + w + (k ? 1 : 0)));
97
+ return ends;
98
+ })();
99
+ /** Wait `ms` milliseconds. Tests pass their own so they don't wait. */
100
+ const realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
101
+ /**
102
+ * Plays a flipbook on a stream: writes the first frame, then moves the cursor
103
+ * back up and redraws each next one in place. Ends on the last frame with a
104
+ * newline. A key press (on a terminal) or `rows` too few to fit the block
105
+ * skips straight to the last frame.
106
+ */
107
+ export async function play(write, book, opts = {}) {
108
+ const last = book.frames[book.frames.length - 1] ?? [];
109
+ // Taller than the window: moving the cursor up would run off the top, so print the result.
110
+ if (!book.frames.length || (opts.rows !== undefined && last.length >= opts.rows)) {
111
+ write(last.join('\n') + '\n');
112
+ return;
113
+ }
114
+ const sleep = opts.sleep ?? realSleep;
115
+ const stopSkip = opts.skipOnKey ? listenForKey() : null;
116
+ try {
117
+ write(book.frames[0].join('\n'));
118
+ for (let k = 1; k < book.frames.length; k++) {
119
+ if (stopSkip?.pressed())
120
+ break;
121
+ await sleep(book.delays[k]);
122
+ write(redraw(book.frames[k]));
123
+ }
124
+ if (stopSkip?.pressed())
125
+ write(redraw(last));
126
+ write('\n');
127
+ }
128
+ finally {
129
+ stopSkip?.stop();
130
+ }
131
+ }
132
+ /** The codes that move back to the block's first line and write `frame` over it, clearing each line first. */
133
+ function redraw(frame) {
134
+ const up = frame.length > 1 ? `\x1b[${frame.length - 1}A` : '';
135
+ return `\r${up}` + frame.map((line) => `\x1b[2K${line}`).join('\n');
136
+ }
137
+ /** Watches the keyboard while an animation plays, so any key skips it. Leaves the terminal as it found it. */
138
+ function listenForKey() {
139
+ const input = process.stdin;
140
+ if (!input.isTTY || typeof input.setRawMode !== 'function')
141
+ return null;
142
+ let hit = false;
143
+ const onData = () => void (hit = true);
144
+ const wasRaw = input.isRaw;
145
+ input.setRawMode(true);
146
+ input.on('data', onData);
147
+ input.resume();
148
+ return {
149
+ pressed: () => hit,
150
+ stop: () => {
151
+ input.off('data', onData);
152
+ input.setRawMode(wasRaw);
153
+ input.pause();
154
+ },
155
+ };
156
+ }
@@ -0,0 +1,295 @@
1
+ // Choosing with the keyboard: a list you move through with ↑ and ↓ (or j and
2
+ // k) and pick with Enter, and a checklist you tick with Space. Drawn on
3
+ // stderr in phantom's style, redrawn in place as you move, and folded into
4
+ // one line (the question and your answer) once you choose. Only for a person
5
+ // at a terminal: scripts, pipes and CI get the plain prompts or flags instead.
6
+ import { emitKeypressEvents } from 'node:readline';
7
+ import { colorOn, paint } from "./style.mjs";
8
+ /** A hint cut to the room left on the line, so a row never wraps in a narrow window. */
9
+ const fitHint = (hint, used, columns) => {
10
+ const room = (columns ?? process.stderr.columns ?? 100) - used - 1;
11
+ return hint.length <= room ? hint : room > 3 ? hint.slice(0, room - 1) + '…' : '';
12
+ };
13
+ /** Thrown by a picker when Ctrl+C is pressed: stop the whole command, wherever it is. */
14
+ export class Cancelled extends Error {
15
+ constructor() {
16
+ super('Cancelled.');
17
+ this.name = 'Cancelled';
18
+ }
19
+ }
20
+ /**
21
+ * Thrown by a command started from the start menu when Esc is pressed on its
22
+ * first step, so the menu shows again instead of the command ending.
23
+ */
24
+ export class BackToMenu extends Error {
25
+ constructor() {
26
+ super('Back to the menu.');
27
+ this.name = 'BackToMenu';
28
+ }
29
+ }
30
+ /** Which way a key moves the marker: -1 up, 1 down, 0 not at all. */
31
+ function step(key) {
32
+ if (key === 'up' || key === 'k' || key === 'shift-tab')
33
+ return -1;
34
+ if (key === 'down' || key === 'j' || key === 'tab')
35
+ return 1;
36
+ return 0;
37
+ }
38
+ /** Whether this run can show a picker: a person at a terminal on both ends, and not CI. */
39
+ export function canPick(env, stderrIsTTY, stdinIsTTY = Boolean(process.stdin.isTTY)) {
40
+ if (env.CI && env.CI !== 'false' && env.CI !== '0')
41
+ return false;
42
+ return Boolean(stderrIsTTY) && stdinIsTTY;
43
+ }
44
+ /**
45
+ * The keyboard, one key at a time, named the way pickers read them: `up`,
46
+ * `down`, `return`, `space`, `escape`, `ctrl-c`, or the letter itself.
47
+ */
48
+ export function keyboard() {
49
+ const input = process.stdin;
50
+ emitKeypressEvents(input);
51
+ const wasRaw = input.isRaw;
52
+ input.setRawMode?.(true);
53
+ input.resume();
54
+ const waiting = [];
55
+ const queued = [];
56
+ const onKey = (text, key = {}) => {
57
+ // A printable character comes through as itself, capitals and all, so the text box gets "Dana", not "dana".
58
+ const printable = Boolean(text && text.length === 1 && text >= ' ' && !key.ctrl && (key.name === undefined || key.name.length === 1));
59
+ const name = key.ctrl && key.name === 'c' ? 'ctrl-c' : key.name === 'enter' ? 'return' : key.shift && key.name === 'tab' ? 'shift-tab' : printable ? text : (key.name ?? text ?? '');
60
+ const resolve = waiting.shift();
61
+ if (resolve)
62
+ resolve(name);
63
+ else
64
+ queued.push(name);
65
+ };
66
+ input.on('keypress', onKey);
67
+ return {
68
+ next: () => new Promise((resolve) => (queued.length ? resolve(queued.shift()) : waiting.push(resolve))),
69
+ close: () => {
70
+ input.off('keypress', onKey);
71
+ input.setRawMode?.(wasRaw);
72
+ input.pause();
73
+ },
74
+ };
75
+ }
76
+ /**
77
+ * Keys from a list, for tests: each `next()` returns the next name, and `end`
78
+ * once the list runs out (which pickers treat as Esc). Every picker in a run reads from the same list, as
79
+ * they would from one keyboard.
80
+ */
81
+ export function scriptedKeys(names) {
82
+ const left = [...names];
83
+ // `end` when the list runs out: pickers read it as Esc; a wait that only listens for Esc ignores it.
84
+ const source = { next: async () => left.shift() ?? 'end', close: () => { } };
85
+ return () => source;
86
+ }
87
+ /** A block of lines that redraws itself in place: the first draw prints it, each later one moves up and writes over it. */
88
+ function block(write) {
89
+ let height = 0;
90
+ return (lines) => {
91
+ const up = height > 1 ? `\x1b[${height - 1}A` : '';
92
+ // \x1b[J clears whatever the last draw left below, when the new one is shorter.
93
+ write((height ? `\r${up}\x1b[J` : '') + lines.join('\n'));
94
+ height = lines.length;
95
+ };
96
+ }
97
+ /** The question line: a lime ? and the question in bold, then the answer once there is one. */
98
+ const asked = (p, question, answer = '') => ` ${p.lime('?')} ${p.bold(question)}${answer ? ` ${p.cyan(answer)}` : ''}`;
99
+ /** The answer a question shows after Esc: back, quit, or cancelled. */
100
+ const escAnswer = (esc) => (esc === 'quit' ? 'quit' : esc === 'cancel' ? 'cancelled' : 'back');
101
+ /** The hint under a picker: which keys do what. */
102
+ const keysHint = (p, words) => ` ${p.dim(words)}`;
103
+ /**
104
+ * Asks a question with a list of answers. ↑ and ↓ (or k and j, or Tab and
105
+ * Shift+Tab) move and Enter chooses. Esc or q returns null, which the caller
106
+ * reads as "go back a step"; Ctrl+C throws {@link Cancelled} to stop the
107
+ * whole command. `start` is where the marker begins.
108
+ */
109
+ export async function choose(io, question, choices, start = 0) {
110
+ const p = paint(io.env, colorOn(io.env, true));
111
+ const draw = block(io.write);
112
+ const width = Math.max(...choices.map((c) => c.label.length)) + 3;
113
+ let at = Math.min(Math.max(0, start), choices.length - 1);
114
+ const frame = () => [
115
+ asked(p, question),
116
+ ...choices.map((c, k) => k === at
117
+ ? ` ${p.lime('❯')} ${p.bold(c.label.padEnd(width))}${p.dim(fitHint(c.hint ?? '', width + 4, io.columns))}`
118
+ : ` ${c.label.padEnd(width)}${p.dim(fitHint(c.hint ?? '', width + 4, io.columns))}`),
119
+ keysHint(p, `↑↓ or tab move · enter choose · esc ${io.esc ?? 'back'} · ctrl+c stop`),
120
+ ];
121
+ const keys = (io.keys ?? keyboard)();
122
+ io.write('\x1b[?25l');
123
+ try {
124
+ draw(frame());
125
+ for (;;) {
126
+ const key = await keys.next();
127
+ if (step(key))
128
+ at = (at + choices.length + step(key)) % choices.length;
129
+ else if (key === 'return') {
130
+ draw([asked(p, question, choices[at].label)]);
131
+ return choices[at].value;
132
+ }
133
+ else if (key === 'escape' || key === 'q' || key === 'end') {
134
+ draw([asked(p, question, escAnswer(io.esc))]);
135
+ return null;
136
+ }
137
+ else if (key === 'ctrl-c') {
138
+ draw([asked(p, question, 'cancelled')]);
139
+ throw new Cancelled();
140
+ }
141
+ else
142
+ continue;
143
+ draw(frame());
144
+ }
145
+ }
146
+ finally {
147
+ io.write('\n\x1b[?25h');
148
+ keys.close();
149
+ }
150
+ }
151
+ /**
152
+ * A checklist: ↑ and ↓ (or Tab) move, Space ticks or unticks, `a` ticks all,
153
+ * Enter goes on with the ticked items (it waits while none is ticked). Esc
154
+ * or q returns null (go back); Ctrl+C throws {@link Cancelled}.
155
+ */
156
+ export async function checklist(io, question, items) {
157
+ const p = paint(io.env, colorOn(io.env, true));
158
+ const draw = block(io.write);
159
+ const ticked = items.map((i) => i.checked !== false);
160
+ const width = Math.max(...items.map((i) => i.label.length)) + 3;
161
+ let at = 0;
162
+ let nudge = '';
163
+ const frame = () => [
164
+ asked(p, question),
165
+ ...items.map((item, k) => {
166
+ const box = ticked[k] ? p.lime('◉') : p.dim('◯');
167
+ const label = k === at ? p.bold(item.label.padEnd(width)) : item.label.padEnd(width);
168
+ return ` ${k === at ? p.lime('❯') : ' '} ${box} ${label}${p.dim(fitHint(item.hint ?? '', width + 6, io.columns))}`;
169
+ }),
170
+ nudge ? ` ${p.yellow(nudge)}` : keysHint(p, `↑↓ or tab move · space tick · a all · enter go on · esc ${io.esc ?? 'back'}`),
171
+ ];
172
+ const keys = (io.keys ?? keyboard)();
173
+ io.write('\x1b[?25l');
174
+ try {
175
+ draw(frame());
176
+ for (;;) {
177
+ const key = await keys.next();
178
+ nudge = '';
179
+ if (step(key))
180
+ at = (at + items.length + step(key)) % items.length;
181
+ else if (key === 'space')
182
+ ticked[at] = !ticked[at];
183
+ else if (key === 'a')
184
+ ticked.fill(!ticked.every(Boolean));
185
+ else if (key === 'return') {
186
+ if (!ticked.some(Boolean))
187
+ nudge = 'Tick at least one, or press esc to go back.';
188
+ else {
189
+ const chosen = items.filter((_, k) => ticked[k]);
190
+ draw([asked(p, question, chosen.map((i) => i.label).join(', '))]);
191
+ return chosen.map((i) => i.value);
192
+ }
193
+ }
194
+ else if (key === 'escape' || key === 'q' || key === 'end') {
195
+ draw([asked(p, question, escAnswer(io.esc))]);
196
+ return null;
197
+ }
198
+ else if (key === 'ctrl-c') {
199
+ draw([asked(p, question, 'cancelled')]);
200
+ throw new Cancelled();
201
+ }
202
+ else
203
+ continue;
204
+ draw(frame());
205
+ }
206
+ }
207
+ finally {
208
+ io.write('\n\x1b[?25h');
209
+ keys.close();
210
+ }
211
+ }
212
+ /**
213
+ * Listens for Esc or Ctrl+C while something waits (sign-in waiting for
214
+ * approval), without taking over the screen. `pressed` settles with the key;
215
+ * other keys are ignored, and the end of a test's keys never settles it.
216
+ */
217
+ export function cancelKeys(source) {
218
+ let closed = false;
219
+ const pressed = new Promise((resolve) => {
220
+ const listen = async () => {
221
+ while (!closed) {
222
+ const key = await source.next();
223
+ if (key === 'end')
224
+ return;
225
+ if (key === 'escape' || key === 'q')
226
+ return resolve('escape');
227
+ if (key === 'ctrl-c')
228
+ return resolve('ctrl-c');
229
+ }
230
+ };
231
+ void listen();
232
+ });
233
+ return {
234
+ pressed,
235
+ close: () => {
236
+ closed = true;
237
+ source.close();
238
+ },
239
+ };
240
+ }
241
+ /**
242
+ * Asks for a short piece of text, such as a key's name or an amount: type it,
243
+ * Backspace to fix it, Enter to go on (it waits while `check` refuses the
244
+ * text, and shows why), Esc to go back (null), Ctrl+C to stop. Keys longer
245
+ * than one character that aren't named keys are typed as text, so a test can
246
+ * send "sam" as one key.
247
+ */
248
+ export async function askText(io, question, opts = {}) {
249
+ const p = paint(io.env, colorOn(io.env, true));
250
+ const draw = block(io.write);
251
+ let text = '';
252
+ let problem = '';
253
+ const NAMED = new Set(['up', 'down', 'left', 'right', 'tab', 'shift-tab', 'space', 'return', 'escape', 'ctrl-c', 'backspace', 'delete', 'end']);
254
+ const frame = () => [
255
+ asked(p, question),
256
+ ` ${p.lime('❯')} ${text ? p.bold(text) : p.dim(opts.placeholder ?? '')}${p.lime('▏')}`,
257
+ problem ? ` ${p.yellow(problem)}` : keysHint(p, `type · enter go on · esc ${io.esc ?? 'back'}`),
258
+ ];
259
+ const keys = (io.keys ?? keyboard)();
260
+ io.write('\x1b[?25l');
261
+ try {
262
+ draw(frame());
263
+ for (;;) {
264
+ const key = await keys.next();
265
+ problem = '';
266
+ if (key === 'return') {
267
+ const why = opts.check?.(text.trim()) ?? (text.trim() ? null : 'Type something, or press esc to go back.');
268
+ if (!why) {
269
+ draw([asked(p, question, text.trim())]);
270
+ return text.trim();
271
+ }
272
+ problem = why;
273
+ }
274
+ else if (key === 'escape' || key === 'end') {
275
+ draw([asked(p, question, escAnswer(io.esc))]);
276
+ return null;
277
+ }
278
+ else if (key === 'ctrl-c') {
279
+ draw([asked(p, question, 'cancelled')]);
280
+ throw new Cancelled();
281
+ }
282
+ else if (key === 'backspace' || key === 'delete')
283
+ text = text.slice(0, -1);
284
+ else if (key === 'space')
285
+ text += ' ';
286
+ else if (!NAMED.has(key))
287
+ text += key;
288
+ draw(frame());
289
+ }
290
+ }
291
+ finally {
292
+ io.write('\n\x1b[?25h');
293
+ keys.close();
294
+ }
295
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Whether to print color: only to a terminal, never with `NO_COLOR` set
3
+ * (https://no-color.org) or on a `dumb` terminal. `FORCE_COLOR` turns it on
4
+ * anywhere, and `FORCE_COLOR=0` turns it off.
5
+ */
6
+ export function colorOn(env, isTTY) {
7
+ if (env.NO_COLOR)
8
+ return false;
9
+ if (env.FORCE_COLOR !== undefined)
10
+ return env.FORCE_COLOR !== '0';
11
+ return Boolean(isTTY) && env.TERM !== 'dumb';
12
+ }
13
+ const wrap = (open, close) => (s) => `\x1b[${open}m${s}\x1b[${close}m`;
14
+ const plain = (s) => s;
15
+ /**
16
+ * The styles for one screen. Lime and cyan use 24-bit color when the terminal says it
17
+ * has it (`COLORTERM=truecolor`), and the nearest of the 256 standard colors otherwise.
18
+ */
19
+ export function paint(env, on) {
20
+ if (!on)
21
+ return { lime: plain, bold: plain, white: plain, pill: plain, dim: plain, cyan: plain, green: plain, red: plain, yellow: plain };
22
+ const truecolor = env.COLORTERM === 'truecolor' || env.COLORTERM === '24bit';
23
+ return {
24
+ lime: wrap(truecolor ? '38;2;62;230;168' : '38;5;79', '39'),
25
+ cyan: wrap(truecolor ? '38;2;0;209;255' : '38;5;45', '39'),
26
+ bold: wrap('1', '22'),
27
+ white: wrap('97', '39'),
28
+ pill: (s) => (truecolor ? `\x1b[48;2;62;230;168m\x1b[38;2;8;9;12m${s}\x1b[39m\x1b[49m` : `\x1b[48;5;79m\x1b[38;5;232m${s}\x1b[39m\x1b[49m`),
29
+ dim: wrap('2', '22'),
30
+ green: wrap('32', '39'),
31
+ red: wrap('31', '39'),
32
+ yellow: wrap('33', '39'),
33
+ };
34
+ }
35
+ /** `text` without color codes, to measure how wide it prints. */
36
+ export const unstyled = (text) => text.replace(/\x1b\[[0-9;]*m/g, '');
@@ -1,2 +1,2 @@
1
1
  /** This release of phantom. `npm version` keeps it equal to the version in package.json. */
2
- export const VERSION = '1.2.3';
2
+ export const VERSION = '1.2.5';