cowork-cli 0.2.8 → 1.1.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/src/utils/ui.js CHANGED
@@ -1,68 +1,582 @@
1
- import { formatSecondary } from './logger.js';
1
+ import { createInterface } from 'node:readline';
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Design tokens
5
+ // ---------------------------------------------------------------------------
6
+
7
+ const COLORS = {
8
+ main: [123, 165, 218], // #7BA5DA – blue (chrome, glyphs, parens)
9
+ tool: [242, 207, 110], // #F2CF6E – amber (label / tool name)
10
+ data: [194, 198, 197], // #C2C6C5 – silver (args, data)
11
+ success: [122, 195, 145], // #7AC391 – green (● on stop)
12
+ error: [224, 112, 112], // #E07070 – red (● on fail)
13
+ dim: [ 96, 96, 96], // #606060 – grey (dim annotations)
14
+ header: [163, 122, 204], // #A37ACC – purple (● header dot)
15
+ };
16
+
17
+ const THOUGHTS = [
18
+ 'Thinking...', 'Brewing...', 'Grooming...', 'Analyzing...',
19
+ 'Investigating...', 'Processing...', 'Synthesizing...', 'Exploring...',
20
+ 'Mapping...', 'Reasoning...', 'Meditating...', 'Computing...',
21
+ 'Crawling...', 'Scanning...',
22
+ ];
23
+
24
+ const GLYPHS = {
25
+ header: '●',
26
+ dot: '●', // color-coded: green = stop, red = fail
27
+ ask: '◇',
28
+ input: '>',
29
+ spinner: ['⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'],
30
+ };
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // State constants
34
+ // ---------------------------------------------------------------------------
35
+
36
+ const STATE = Object.freeze({
37
+ IDLE: 'IDLE',
38
+ SPINNING: 'SPINNING',
39
+ THINKING: 'THINKING',
40
+ ASKING: 'ASKING',
41
+ });
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // UIEngine
45
+ // ---------------------------------------------------------------------------
2
46
 
3
47
  /**
4
- * A minimalist terminal spinner that uses text-based dot animations.
48
+ * UIEngine State-Based Reactive Terminal Interface.
49
+ *
50
+ * State machine:
51
+ *
52
+ * IDLE ──start()──▶ SPINNING
53
+ * IDLE ──think()──▶ THINKING
54
+ * IDLE ──ask()───▶ ASKING
55
+ * SPINNING/THINKING ──stop()──▶ IDLE (● green)
56
+ * SPINNING/THINKING ──fail()──▶ IDLE (● red)
57
+ * ASKING ──resolve/cancel──▶ IDLE
58
+ *
59
+ * Reactive renderer:
60
+ * _vdom tracks { frame, label, data } currently on screen.
61
+ * Each tick diffs next values against _vdom and calls only the
62
+ * narrowest patch operation needed — no full-line clears on animation ticks.
63
+ *
64
+ * Patch cost per tick (SPINNING, no update):
65
+ * \r + 1 colored char ≈ 22 bytes (no clear)
66
+ *
67
+ * Public API:
68
+ * ui.start(label, data?) → SPINNING
69
+ * ui.think() → THINKING (rotating thought words)
70
+ * ui.update(data) – swap data field in-place
71
+ * ui.stop(msg?) → IDLE (● green)
72
+ * ui.fail(msg?) → IDLE (● red)
73
+ * ui.ask(question) → Promise<string>
74
+ * ui.log(text) – safe from any state
75
+ * ui.header(title) – safe from any state
76
+ * ui.footer(secs) – safe from any state
77
+ * ui.cleanup() – restore cursor, safe from any state
78
+ * ui.state – read-only current state string
5
79
  */
6
- export class Spinner {
80
+ export class UIEngine {
7
81
  constructor() {
8
- this.frames = ['', '.', '..', '...'];
9
- this.interval = null;
10
- this.currentFrame = 0;
11
- this.text = '';
12
- this.isSpinning = false;
82
+ this._stream = process.stdout;
83
+ this._state = STATE.IDLE;
84
+ this._timer = null;
85
+ this._frameIdx = 0;
86
+ this._thoughtIdx = 0;
87
+
88
+ // What the spinner is currently representing
89
+ this._ctx = { label: '', data: '' };
90
+
91
+ // What is actually rendered on the terminal right now
92
+ // null = field not yet on screen / cleared
93
+ this._vdom = { frame: null, label: null, data: null };
94
+
95
+ // Re-render on resize — layout metrics change with terminal width
96
+ this._stream.on('resize', () => {
97
+ if (this._state === STATE.SPINNING || this._state === STATE.THINKING) {
98
+ this._paint(); // full repaint: truncation bounds changed
99
+ }
100
+ });
101
+ }
102
+
103
+ // -------------------------------------------------------------------------
104
+ // Public state getter
105
+ // -------------------------------------------------------------------------
106
+
107
+ /** @returns {'IDLE'|'SPINNING'|'THINKING'|'ASKING'} */
108
+ get state() { return this._state; }
109
+
110
+ // -------------------------------------------------------------------------
111
+ // ANSI helpers (pure, no side-effects)
112
+ // -------------------------------------------------------------------------
113
+
114
+ _rgb([r, g, b], text) { return `\x1b[38;2;${r};${g};${b}m${text}\x1b[0m`; }
115
+ _bold(text) { return `\x1b[1m${text}\x1b[0m`; }
116
+ _dim(text) { return `\x1b[2m${text}\x1b[0m`; }
117
+
118
+ // -------------------------------------------------------------------------
119
+ // Terminal control
120
+ // -------------------------------------------------------------------------
121
+
122
+ _w(str) { this._stream.write(str); }
123
+ _clearLine() { this._w('\r\x1b[K'); }
124
+ _hideCursor() { this._w('\x1b[?25l'); }
125
+ _showCursor() { this._w('\x1b[?25h'); }
126
+ _moveUp(n = 1) { this._w(`\x1b[${n}A`); }
127
+ /** Move cursor to visible column n (0-indexed). */
128
+ _toCol(n) { this._w(n > 0 ? `\r\x1b[${n}C` : '\r'); }
129
+
130
+ // -------------------------------------------------------------------------
131
+ // Layout helpers
132
+ // -------------------------------------------------------------------------
133
+
134
+ /**
135
+ * How many visible chars are available for the data field,
136
+ * given the current terminal width and label length.
137
+ *
138
+ * Line layout (visible columns):
139
+ * frame(1) space(1) label(L) space(1) open(1) data(D) close(1)
140
+ * total = 5 + L + D → D = width - 5 - L - margin(2)
141
+ */
142
+ _availWidth(label) {
143
+ const w = this._stream.columns || 80;
144
+ return Math.max(0, w - 7 - label.length);
145
+ }
146
+
147
+ /**
148
+ * Column where '(' starts = frame(1) + space(1) + label(L) + space(1)
149
+ * = 3 + L
150
+ */
151
+ _parenCol(label) { return 3 + label.length; }
152
+
153
+ // -------------------------------------------------------------------------
154
+ // Smart truncation
155
+ // -------------------------------------------------------------------------
156
+
157
+ /**
158
+ * Truncate to maxWidth visible chars.
159
+ * Paths: prefer …/parent/file → …/file → …tail.
160
+ * Other: tail-ellipsis.
161
+ */
162
+ _truncate(text, maxWidth) {
163
+ if (!text || text.length <= maxWidth) return text;
164
+ if (maxWidth <= 2) return '…';
165
+
166
+ if (text.includes('/')) {
167
+ const parts = text.split('/');
168
+ const file = parts.pop();
169
+ const par = parts.pop() || '';
170
+
171
+ const wp = `…/${par}/${file}`;
172
+ if (wp.length <= maxWidth) return wp;
173
+
174
+ const wf = `…/${file}`;
175
+ if (wf.length <= maxWidth) return wf;
176
+
177
+ return `…${file.slice(-(maxWidth - 1))}`;
178
+ }
179
+
180
+ return text.slice(0, maxWidth - 1) + '…';
13
181
  }
14
182
 
183
+ // -------------------------------------------------------------------------
184
+ // Patch operations — surgical cursor-positioned writes, no full-line clears
185
+ // -------------------------------------------------------------------------
186
+
15
187
  /**
16
- * Starts the spinner with a base message.
188
+ * Overwrite only the spinner frame character at col 0.
189
+ * Cost: \r + ~22 bytes of ANSI + 1 char. Rest of line untouched.
17
190
  */
18
- start(text) {
19
- if (this.isSpinning) this.stop(true);
20
- process.stdout.write('\x1b[?25l'); // Hide cursor
21
- this.text = text;
22
- this.isSpinning = true;
23
- this.render();
24
- this.interval = setInterval(() => {
25
- this.currentFrame = (this.currentFrame + 1) % this.frames.length;
26
- this.render();
27
- }, 400); // Slower interval for dot cycle
191
+ _patchFrame(frame) {
192
+ this._w(`\r${this._rgb(COLORS.main, frame)}`);
193
+ // cursor lands at col 1 — the rest of the line is physically untouched
28
194
  }
29
195
 
30
196
  /**
31
- * Updates the base text message.
197
+ * Overwrite label and everything to the right of it (col 2 onward).
198
+ * Used when label changes (THINKING rotation) or on initial paint for label region.
199
+ * Returns the truncated data string that was written.
32
200
  */
33
- update(text) {
34
- this.text = text;
35
- if (this.isSpinning) this.render();
201
+ _patchFromLabel(label, data) {
202
+ const td = data ? this._truncate(data, this._availWidth(label)) : '';
203
+ const dataStr = td
204
+ ? ` ${this._rgb(COLORS.main, '(')}${this._rgb(COLORS.data, td)}${this._rgb(COLORS.main, ')')}`
205
+ : '';
206
+ // go to col 2, clear to EOL, write new label + optional data
207
+ this._w(`\r\x1b[2C\x1b[K${this._rgb(COLORS.tool, label)}${dataStr}`);
208
+ return td;
36
209
  }
37
210
 
38
211
  /**
39
- * Stops the spinner and restores the cursor.
40
- * @param {boolean} clear If true, clears the entire line.
212
+ * Overwrite only the data field (from the '(' position onward).
213
+ * Used when data changes via update() label and frame untouched.
214
+ * Returns the truncated data string that was written.
41
215
  */
42
- stop(clear = true) {
43
- if (!this.isSpinning) {
44
- process.stdout.write('\x1b[?25h'); // Ensure cursor is shown
45
- return;
216
+ _patchFromData(label, data) {
217
+ const td = data ? this._truncate(data, this._availWidth(label)) : '';
218
+ const dataStr = td
219
+ ? `${this._rgb(COLORS.main, '(')}${this._rgb(COLORS.data, td)}${this._rgb(COLORS.main, ')')}`
220
+ : '';
221
+ // jump to paren col, clear to EOL, write new data section
222
+ this._toCol(this._parenCol(label));
223
+ this._w(`\x1b[K${dataStr}`);
224
+ return td;
225
+ }
226
+
227
+ // -------------------------------------------------------------------------
228
+ // Full repaint — only at state transitions or terminal resize
229
+ // -------------------------------------------------------------------------
230
+
231
+ _paint() {
232
+ const { label, data } = this._ctx;
233
+ const frame = GLYPHS.spinner[this._frameIdx];
234
+ const td = data ? this._truncate(data, this._availWidth(label)) : '';
235
+ const dataStr = td
236
+ ? ` ${this._rgb(COLORS.main, '(')}${this._rgb(COLORS.data, td)}${this._rgb(COLORS.main, ')')}`
237
+ : '';
238
+
239
+ this._clearLine(); // ← one of the very few places \r\x1b[K appears
240
+ this._w(`${this._rgb(COLORS.main, frame)} ${this._rgb(COLORS.tool, label)}${dataStr}`);
241
+
242
+ // Sync virtual DOM to match what's on screen
243
+ this._vdom = { frame, label, data: td };
244
+ }
245
+
246
+ // -------------------------------------------------------------------------
247
+ // Animation tick — diffed, minimal writes
248
+ // -------------------------------------------------------------------------
249
+
250
+ _tick() {
251
+ this._frameIdx = (this._frameIdx + 1) % GLYPHS.spinner.length;
252
+
253
+ const nextFrame = GLYPHS.spinner[this._frameIdx];
254
+ let nextLabel = this._ctx.label;
255
+
256
+ // THINKING: rotate label every 8 ticks (~800 ms at 100 ms interval)
257
+ if (this._state === STATE.THINKING && this._frameIdx % 8 === 0) {
258
+ this._thoughtIdx = (this._thoughtIdx + 1) % THOUGHTS.length;
259
+ nextLabel = THOUGHTS[this._thoughtIdx];
260
+ this._ctx.label = nextLabel;
46
261
  }
47
- clearInterval(this.interval);
48
- this.isSpinning = false;
49
- if (clear) {
50
- process.stdout.write('\r\x1b[K'); // Clear entire line
262
+
263
+ const labelChanged = nextLabel !== this._vdom.label;
264
+ const frameChanged = nextFrame !== this._vdom.frame;
265
+
266
+ if (labelChanged) {
267
+ // Repaint from col 2 onward (label + data)
268
+ const td = this._patchFromLabel(nextLabel, this._ctx.data);
269
+ this._vdom.label = nextLabel;
270
+ this._vdom.data = td; // avail width may have changed with new label length
271
+ }
272
+
273
+ if (frameChanged) {
274
+ // Cheapest possible update: 1 char at col 0
275
+ this._patchFrame(nextFrame);
276
+ this._vdom.frame = nextFrame;
277
+ }
278
+ }
279
+
280
+ // -------------------------------------------------------------------------
281
+ // Internal transition helpers
282
+ // -------------------------------------------------------------------------
283
+
284
+ /**
285
+ * Silently clear the active spinner line and stop the timer.
286
+ * No completion line printed. Used when start()/think() replaces
287
+ * an existing spinner, or on cleanup().
288
+ */
289
+ _abort() {
290
+ if (this._state === STATE.IDLE || this._state === STATE.ASKING) return;
291
+ clearInterval(this._timer);
292
+ this._timer = null;
293
+ this._clearLine();
294
+ this._vdom = { frame: null, label: null, data: null };
295
+ this._state = STATE.IDLE;
296
+ this._showCursor();
297
+ }
298
+
299
+ /**
300
+ * Stop the spinner and commit a styled completion line.
301
+ * @param {string|undefined} msg - override data shown in parens
302
+ * @param {number[]} color - COLORS.success or COLORS.error
303
+ */
304
+ _commit(msg, color) {
305
+ if (this._state === STATE.IDLE || this._state === STATE.ASKING) return;
306
+ clearInterval(this._timer);
307
+ this._timer = null;
308
+ this._clearLine();
309
+
310
+ const label = this._state === STATE.THINKING ? 'Thought' : this._ctx.label;
311
+ const raw = msg !== undefined ? msg : this._ctx.data;
312
+ const td = raw ? this._truncate(raw, this._availWidth(label)) : '';
313
+ const dataStr = td
314
+ ? ` ${this._rgb(COLORS.main, '(')}${this._rgb(COLORS.data, td)}${this._rgb(COLORS.main, ')')}`
315
+ : '';
316
+
317
+ this._w(`${this._rgb(color, GLYPHS.dot)} ${this._rgb(COLORS.tool, label)}${dataStr}\n`);
318
+ this._vdom = { frame: null, label: null, data: null };
319
+ this._state = STATE.IDLE;
320
+ this._showCursor();
321
+ }
322
+
323
+ // -------------------------------------------------------------------------
324
+ // Public API
325
+ // -------------------------------------------------------------------------
326
+
327
+ /**
328
+ * Enter SPINNING state with a fixed label.
329
+ * Silently replaces any active spinner.
330
+ */
331
+ start(label, data = '') {
332
+ this._abort();
333
+ this._ctx = { label, data };
334
+ this._frameIdx = 0;
335
+ this._state = STATE.SPINNING;
336
+ this._hideCursor();
337
+ this._paint();
338
+ this._timer = setInterval(() => this._tick(), 100);
339
+ }
340
+
341
+ /**
342
+ * Enter THINKING state — label rotates through THOUGHTS words.
343
+ * Silently replaces any active spinner.
344
+ */
345
+ think() {
346
+ this._abort();
347
+ this._thoughtIdx = 0;
348
+ this._ctx = { label: THOUGHTS[0], data: '' };
349
+ this._frameIdx = 0;
350
+ this._state = STATE.THINKING;
351
+ this._hideCursor();
352
+ this._paint();
353
+ this._timer = setInterval(() => this._tick(), 100);
354
+ }
355
+
356
+ /**
357
+ * Swap the data field of a running spinner without stopping it.
358
+ * Only writes to terminal if the (truncated) value actually changed.
359
+ */
360
+ update(data) {
361
+ if (this._state !== STATE.SPINNING && this._state !== STATE.THINKING) return;
362
+ const td = data ? this._truncate(data, this._availWidth(this._ctx.label)) : '';
363
+ if (td === this._vdom.data) return; // nothing visible changed
364
+ this._ctx.data = data;
365
+ const written = this._patchFromData(this._ctx.label, data);
366
+ this._vdom.data = written;
367
+ }
368
+
369
+ /**
370
+ * Finish the spinner with a green ● and an optional final message.
371
+ * SPINNING/THINKING → IDLE.
372
+ */
373
+ stop(msg) { this._commit(msg, COLORS.success); }
374
+
375
+ /**
376
+ * Finish the spinner with a red ● and an optional final message.
377
+ * SPINNING/THINKING → IDLE.
378
+ */
379
+ fail(msg) { this._commit(msg, COLORS.error); }
380
+
381
+ /**
382
+ * Print a line of text without disturbing the active spinner.
383
+ * Safe from any state.
384
+ * @param {string} text Pre-formatted string (may contain ANSI codes).
385
+ */
386
+ log(text) {
387
+ if (this._state === STATE.SPINNING || this._state === STATE.THINKING) {
388
+ this._clearLine();
389
+ this._w(`${text}\n`);
390
+ this._paint(); // restore spinner below
51
391
  } else {
52
- process.stdout.write('\n');
392
+ this._w(`${text}\n`);
53
393
  }
54
- process.stdout.write('\x1b[?25h'); // Show cursor
55
- this.currentFrame = 0;
56
394
  }
57
395
 
58
396
  /**
59
- * @private
397
+ * Print a ● header line. Safe from any state via log().
398
+ */
399
+ header(title) {
400
+ this.log(
401
+ `${this._rgb(COLORS.header, GLYPHS.header)} ${this._rgb(COLORS.main, this._bold(title.toLowerCase()))}`
402
+ );
403
+ }
404
+
405
+ /**
406
+ * Prompt the user for input with a styled ◇ Question prompt.
407
+ * Auto-stops any active spinner first.
408
+ * Resolves with the trimmed answer; rejects { cancelled: true } on SIGINT.
409
+ * Re-prompts silently on empty input.
410
+ * @param {string} question
411
+ * @returns {Promise<string>}
412
+ */
413
+ ask(question) {
414
+ this._abort();
415
+
416
+ if (!process.stdin.isTTY) {
417
+ return Promise.reject(new Error('stdin is not a TTY'));
418
+ }
419
+
420
+ this._state = STATE.ASKING;
421
+
422
+ return new Promise((resolve, reject) => {
423
+ const rl = createInterface({ input: process.stdin, output: this._stream });
424
+
425
+ const w = this._stream.columns || 80;
426
+ const avail = Math.max(0, w - 14); // ◇ Ask ( ... )
427
+ const truncQ = this._truncate(question, avail);
428
+
429
+ this._w(
430
+ `${this._rgb(COLORS.main, GLYPHS.ask)} ` +
431
+ `${this._rgb(COLORS.tool, 'Ask')} ` +
432
+ `${this._rgb(COLORS.main, '(')}${this._rgb(COLORS.data, truncQ)}${this._rgb(COLORS.main, ')')}\n`
433
+ );
434
+
435
+ const prompt = `${this._rgb(COLORS.main, this._bold(GLYPHS.input))} `;
436
+
437
+ rl.on('SIGINT', () => {
438
+ rl.close();
439
+ this._showCursor();
440
+ this._state = STATE.IDLE;
441
+ reject({ cancelled: true });
442
+ });
443
+
444
+ const doAsk = () => {
445
+ rl.question(prompt, (raw) => {
446
+ const ans = raw.trim();
447
+ if (!ans) {
448
+ this._moveUp(1);
449
+ this._clearLine();
450
+ doAsk();
451
+ return;
452
+ }
453
+ rl.close();
454
+ this._moveUp(1);
455
+ this._clearLine();
456
+ this._w(`${this._rgb(COLORS.main, this._bold(GLYPHS.input))} ${ans}\n`);
457
+ this._state = STATE.IDLE;
458
+ resolve(ans);
459
+ });
460
+ };
461
+
462
+ doAsk();
463
+ });
464
+ }
465
+
466
+ /**
467
+ * Prompt the user for a yes/no confirmation with an interactive toggle.
468
+ *
469
+ * Behaviour:
470
+ * • Renders a `[ Yes ] No` toggle selector.
471
+ * • Use Arrow Keys, Tab, or Space to toggle. Y/N to select directly.
472
+ * • Enter to confirm.
473
+ * • Ctrl+C (SIGINT) → resolves { confirmed: false, dismissed: true }.
474
+ * • Answer is echoed in green (yes) or red (no).
475
+ *
476
+ * @param {string} question
477
+ * @returns {Promise<{ confirmed: boolean, dismissed?: true }>}
478
+ */
479
+ confirm(question) {
480
+ this._abort();
481
+
482
+ if (!process.stdin.isTTY) {
483
+ return Promise.resolve({ confirmed: false, dismissed: true });
484
+ }
485
+
486
+ this._state = STATE.ASKING;
487
+
488
+ return new Promise((resolve) => {
489
+ const w = this._stream.columns || 80;
490
+ const avail = Math.max(0, w - 14);
491
+ const truncQ = this._truncate(question, avail);
492
+
493
+ this._w(
494
+ `${this._rgb(COLORS.main, GLYPHS.ask)} ` +
495
+ `${this._rgb(COLORS.tool, 'Ask')} ` +
496
+ `${this._rgb(COLORS.main, '(')}${this._rgb(COLORS.data, truncQ)}${this._rgb(COLORS.main, ')')}\n`
497
+ );
498
+
499
+ let selectedYes = true;
500
+
501
+ const renderToggle = () => {
502
+ this._clearLine();
503
+ const yesLabel = selectedYes ? this._rgb(COLORS.main, this._bold('[ Yes ]')) : this._dim(' Yes ');
504
+ const noLabel = !selectedYes ? this._rgb(COLORS.main, this._bold('[ No ]')) : this._dim(' No ');
505
+ this._w(`${this._rgb(COLORS.main, this._bold(GLYPHS.input))} ${yesLabel} ${noLabel}`);
506
+ };
507
+
508
+ this._hideCursor();
509
+ renderToggle();
510
+
511
+ const onData = (data) => {
512
+ const str = data.toString();
513
+
514
+ // Ctrl+C
515
+ if (str === '\u0003') {
516
+ cleanup();
517
+ this._clearLine();
518
+ this._w(`${this._rgb(COLORS.main, this._bold(GLYPHS.input))} ${this._dim('cancelled')}\n`);
519
+ this._showCursor();
520
+ this._state = STATE.IDLE;
521
+ resolve({ confirmed: false, dismissed: true });
522
+ return;
523
+ }
524
+
525
+ // Enter
526
+ if (str === '\r' || str === '\n') {
527
+ cleanup();
528
+ this._clearLine();
529
+ const finalColor = selectedYes ? COLORS.success : COLORS.error;
530
+ const finalStr = selectedYes ? 'yes' : 'no';
531
+ this._w(`${this._rgb(COLORS.main, this._bold(GLYPHS.input))} ${this._rgb(finalColor, finalStr)}\n`);
532
+ this._showCursor();
533
+ this._state = STATE.IDLE;
534
+ resolve({ confirmed: selectedYes });
535
+ return;
536
+ }
537
+
538
+ // Toggle keys (Left, Right, Up, Down, Tab, Space)
539
+ if (str === '\u001b[D' || str === '\u001b[C' || str === '\u001b[A' || str === '\u001b[B' || str === '\t' || str === ' ') {
540
+ selectedYes = !selectedYes;
541
+ renderToggle();
542
+ } else if (str.toLowerCase() === 'y') {
543
+ selectedYes = true;
544
+ renderToggle();
545
+ } else if (str.toLowerCase() === 'n') {
546
+ selectedYes = false;
547
+ renderToggle();
548
+ }
549
+ };
550
+
551
+ const cleanup = () => {
552
+ if (process.stdin.isTTY) {
553
+ process.stdin.setRawMode(false);
554
+ }
555
+ process.stdin.off('data', onData);
556
+ process.stdin.pause();
557
+ };
558
+
559
+ if (process.stdin.isTTY) {
560
+ process.stdin.setRawMode(true);
561
+ }
562
+ process.stdin.resume();
563
+ process.stdin.on('data', onData);
564
+ });
565
+ }
566
+
567
+
568
+ footer(duration) {
569
+ this.log(`${this._dim('time')} ${this._rgb(COLORS.main, duration + 's')}`);
570
+ }
571
+
572
+ /**
573
+ * Restore cursor and stop timer. Safe to call from SIGINT / uncaughtException.
60
574
  */
61
- render() {
62
- const dots = this.frames[this.currentFrame];
63
- // Renders the text followed by the cycling dots
64
- process.stdout.write(`\r\x1b[K${formatSecondary(`${this.text}${dots}`)}`);
575
+ cleanup() {
576
+ this._abort();
577
+ this._showCursor();
65
578
  }
66
579
  }
67
580
 
68
- export const spinner = new Spinner();
581
+ // Singleton import { ui } wherever you need the terminal interface.
582
+ export const ui = new UIEngine();