ispbills-icli 8.5.2 → 8.5.4

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.
@@ -5,6 +5,9 @@ import { html, useState, useEffect, useCallback, useRef } from './dom.js';
5
5
  import { Box, Text, useInput } from 'ink';
6
6
  import { C, glyphs } from './theme.js';
7
7
  import { SLASH_COMMANDS } from '../commands.js';
8
+ import { readFileSync, writeFileSync, rmSync } from 'fs';
9
+ import { join } from 'path';
10
+ import { spawnSync } from 'child_process';
8
11
 
9
12
  // @-mention entity types. Selecting one inserts e.g. "@olt:" so the user can
10
13
  // append an identifier (@olt:3); the backend treats it as a scoped reference.
@@ -20,34 +23,89 @@ const MENTIONS = [
20
23
  { tag: '@mac', desc: 'A MAC address' },
21
24
  ];
22
25
 
23
- export function Composer({ onSubmit, busy, width = 80 }) {
26
+ function insertAt(text, cursor, chunk) {
27
+ return text.slice(0, cursor) + chunk + text.slice(cursor);
28
+ }
29
+
30
+ function wordLeft(text, cursor) {
31
+ let i = cursor;
32
+ while (i > 0 && /\s/.test(text[i - 1])) i--;
33
+ while (i > 0 && !/\s/.test(text[i - 1])) i--;
34
+ return i;
35
+ }
36
+
37
+ function wordRight(text, cursor) {
38
+ let i = cursor;
39
+ while (i < text.length && /\s/.test(text[i])) i++;
40
+ while (i < text.length && !/\s/.test(text[i])) i++;
41
+ return i;
42
+ }
43
+
44
+ function openExternalEditor(currentValue) {
45
+ const file = join(process.cwd(), `.icli-compose-${process.pid}.txt`);
46
+ const editor = process.env.VISUAL || process.env.EDITOR || 'vi';
47
+ try {
48
+ writeFileSync(file, currentValue, { mode: 0o600 });
49
+ const res = spawnSync(editor, [file], { stdio: 'inherit', shell: true });
50
+ if (res.error) throw res.error;
51
+ return { ok: true, text: readFileSync(file, 'utf8') };
52
+ } catch (error) {
53
+ return { ok: false, error: error?.message || String(error) };
54
+ } finally {
55
+ try { rmSync(file, { force: true }); } catch {}
56
+ }
57
+ }
58
+
59
+ export function Composer({
60
+ onSubmit,
61
+ onSubmitKeep,
62
+ onEmptySubmit,
63
+ onQueue,
64
+ onSearch,
65
+ onNotice,
66
+ onEmptyEsc,
67
+ onValueChange,
68
+ busy,
69
+ blocked = false,
70
+ width = 80,
71
+ mode = 'ask',
72
+ clearToken = 0,
73
+ }) {
24
74
  const [value, setValue] = useState('');
25
75
  const [cursor, setCursor] = useState(0);
26
76
  const [history, setHistory] = useState([]);
27
77
  const [histIdx, setHistIdx] = useState(-1);
28
78
  const [sel, setSel] = useState(0);
79
+ const [histSearch, setHistSearch] = useState(false);
80
+ const [histSearchQ, setHistSearchQ] = useState('');
81
+ const [histSearchSel, setHistSearchSel] = useState(0);
29
82
 
30
- // Undo/redo history: snapshots of { value, cursor }. Consecutive typing is
31
- // coalesced into a single undo step via `lastMut`.
32
83
  const undoRef = useRef([]);
33
84
  const redoRef = useRef([]);
34
85
  const lastMut = useRef('');
86
+ const pasteRef = useRef({ active: false, buf: '' });
87
+
35
88
  const snapshot = (kind) => {
36
- if (kind === 'type' && lastMut.current === 'type') return; // coalesce a typing run
89
+ if (kind === 'type' && lastMut.current === 'type') return;
37
90
  undoRef.current.push({ value, cursor });
38
91
  if (undoRef.current.length > 200) undoRef.current.shift();
39
92
  redoRef.current = [];
40
93
  lastMut.current = kind;
41
94
  };
42
95
 
43
- // Bracketed-paste assembly. A right-click / Cmd+V paste is wrapped by the
44
- // terminal in \x1b[200~ … \x1b[201~ and can arrive split across several
45
- // reads, so we buffer until the end marker before inserting.
46
- const pasteRef = useRef({ active: false, buf: '' });
96
+ useEffect(() => {
97
+ setValue('');
98
+ setCursor(0);
99
+ setHistIdx(-1);
100
+ setHistSearch(false);
101
+ setHistSearchQ('');
102
+ setHistSearchSel(0);
103
+ }, [clearToken]);
104
+
105
+ useEffect(() => {
106
+ onValueChange?.(value);
107
+ }, [value, onValueChange]);
47
108
 
48
- // Insert pasted text at the cursor, PRESERVING newlines so multi-line pastes
49
- // (configs, logs) keep their structure. Tabs → 2 spaces; other control chars
50
- // and the bracketed-paste markers are dropped.
51
109
  const insertPaste = (raw) => {
52
110
  const text = raw
53
111
  .replace(/\x1b?\[20[01]~/g, '')
@@ -56,11 +114,10 @@ export function Composer({ onSubmit, busy, width = 80 }) {
56
114
  .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '');
57
115
  if (!text) return;
58
116
  snapshot('paste');
59
- setValue((v) => v.slice(0, cursor) + text + v.slice(cursor));
117
+ setValue((v) => insertAt(v, cursor, text));
60
118
  setCursor((c) => c + text.length);
61
119
  };
62
120
 
63
- // Determine the token being typed at the cursor to drive the palette.
64
121
  const upto = value.slice(0, cursor);
65
122
  const tokStart = Math.max(upto.lastIndexOf(' '), upto.lastIndexOf('\n')) + 1;
66
123
  const token = upto.slice(tokStart);
@@ -73,44 +130,101 @@ export function Composer({ onSubmit, busy, width = 80 }) {
73
130
  : [];
74
131
  const menuOpen = matches.length > 0;
75
132
 
76
- useEffect(() => { setSel((i) => Math.min(i, Math.max(0, matches.length - 1))); }, [matches.length]);
133
+ const historyMatches = [...history]
134
+ .reverse()
135
+ .filter((h) => !histSearchQ || h.toLowerCase().includes(histSearchQ.toLowerCase()));
136
+
137
+ useEffect(() => {
138
+ setSel((i) => Math.min(i, Math.max(0, matches.length - 1)));
139
+ }, [matches.length]);
140
+
141
+ useEffect(() => {
142
+ setHistSearchSel((i) => Math.min(i, Math.max(0, historyMatches.length - 1)));
143
+ }, [histSearchQ, historyMatches.length]);
77
144
 
78
- const submit = useCallback((text) => {
145
+ const rememberHistory = useCallback((q) => {
146
+ if (!q) return;
147
+ setHistory((h) => (h[h.length - 1] === q ? h : [...h, q]));
148
+ }, []);
149
+
150
+ const submit = useCallback((text, { preserve = false } = {}) => {
79
151
  const q = text.trim();
80
- setValue(''); setCursor(0); setHistIdx(-1); setSel(0);
81
- undoRef.current = []; redoRef.current = []; lastMut.current = '';
82
- if (q) {
83
- setHistory((h) => (h[h.length - 1] === q ? h : [...h, q]));
152
+ if (!q) {
153
+ onEmptySubmit?.();
154
+ return;
155
+ }
156
+ rememberHistory(q);
157
+ setHistIdx(-1);
158
+ setSel(0);
159
+ setHistSearch(false);
160
+ setHistSearchQ('');
161
+ setHistSearchSel(0);
162
+ undoRef.current = [];
163
+ redoRef.current = [];
164
+ lastMut.current = '';
165
+ if (!preserve) {
166
+ setValue('');
167
+ setCursor(0);
84
168
  onSubmit(q);
169
+ return;
85
170
  }
86
- }, [onSubmit]);
171
+ onSubmitKeep ? onSubmitKeep(q) : onSubmit(q);
172
+ }, [onSubmit, onSubmitKeep, onEmptySubmit, rememberHistory]);
87
173
 
88
174
  const setText = (v, c) => { setValue(v); setCursor(c ?? v.length); };
89
175
 
90
- // Complete the active token to the highlighted palette entry.
91
176
  const complete = () => {
92
177
  if (!menuOpen) return;
93
- if (slashMode) { const c = matches[sel].cmd + ' '; setText(c, c.length); return; }
178
+ if (slashMode) {
179
+ const c = matches[sel].cmd + ' ';
180
+ setText(c, c.length);
181
+ return;
182
+ }
94
183
  const ins = matches[sel].tag + ':';
95
184
  const v = value.slice(0, tokStart) + ins + value.slice(cursor);
96
185
  setText(v, tokStart + ins.length);
97
186
  };
98
187
 
99
188
  useInput((input, key) => {
100
- if (busy) return; // ignore edits while a turn streams (App handles Ctrl+C/Esc)
189
+ const enter = key.return || input === '\r' || input === '\n';
190
+ const ctrlEnter = busy && key.ctrl && enter;
191
+ if (ctrlEnter || (busy && key.ctrl && input === 'q')) {
192
+ const q = value.trim();
193
+ if (q) onQueue?.(q);
194
+ else onNotice?.('Nothing to queue.', C.muted);
195
+ return;
196
+ }
197
+ if (blocked) return;
198
+ if (busy) return;
199
+
200
+ if (histSearch) {
201
+ if (key.escape) { setHistSearch(false); setHistSearchQ(''); setHistSearchSel(0); return; }
202
+ if (key.upArrow) { setHistSearchSel((i) => Math.max(0, i - 1)); return; }
203
+ if (key.downArrow || key.tab) { setHistSearchSel((i) => Math.min(historyMatches.length - 1, i + 1)); return; }
204
+ if (enter) {
205
+ const picked = historyMatches[histSearchSel];
206
+ if (picked) setText(picked);
207
+ setHistSearch(false);
208
+ return;
209
+ }
210
+ if (key.backspace || key.delete || (key.ctrl && input === 'h')) {
211
+ setHistSearchQ((q) => q.slice(0, -1));
212
+ return;
213
+ }
214
+ if (input && !key.ctrl && !key.meta) {
215
+ const printable = input.replace(/[\x00-\x1f\x7f]/g, '');
216
+ if (printable) setHistSearchQ((q) => q + printable);
217
+ }
218
+ return;
219
+ }
101
220
 
102
- // ── Bracketed paste (mouse right-click / Cmd+V), possibly split reads ──
103
- // Ink strips the leading ESC, so the terminal's \x1b[200~ … \x1b[201~
104
- // wrappers arrive as the marker text "[200~" / "[201~" (sometimes as their
105
- // own useInput calls). Buffer everything between them so embedded newlines
106
- // are never treated as Enter, then insert the whole block at once.
107
221
  const START = '[200~';
108
222
  const END = '[201~';
109
223
  if (pasteRef.current.active || input.includes(START)) {
110
224
  pasteRef.current.active = true;
111
225
  pasteRef.current.buf += input;
112
226
  const e = pasteRef.current.buf.indexOf(END);
113
- if (e === -1) return; // still receiving the paste
227
+ if (e === -1) return;
114
228
  const s = pasteRef.current.buf.indexOf(START);
115
229
  const raw = pasteRef.current.buf.slice(s + START.length, e);
116
230
  pasteRef.current = { active: false, buf: '' };
@@ -118,38 +232,70 @@ export function Composer({ onSubmit, busy, width = 80 }) {
118
232
  return;
119
233
  }
120
234
 
121
- // ── Submit / complete ──
122
- // Some terminals/PTYs deliver Enter as input="\r" without key.return, so
123
- // treat a lone CR/LF as Enter too (a paste is a longer chunk, never "\r").
124
- const enter = key.return || input === '\r' || input === '\n';
235
+ if (key.ctrl && input === 'g') {
236
+ const edited = openExternalEditor(value);
237
+ if (!edited.ok) {
238
+ onNotice?.(`External editor failed: ${edited.error}`, C.error);
239
+ return;
240
+ }
241
+ setText(edited.text);
242
+ onNotice?.('Loaded text from external editor.', C.success);
243
+ return;
244
+ }
245
+ if ((key.ctrl && input === 'v') && !pasteRef.current.active) {
246
+ onNotice?.('Paste attachment: paste an image or text via your terminal\'s paste shortcut (Ctrl+Shift+V on Linux, Cmd+V on Mac).', C.muted);
247
+ return;
248
+ }
249
+ if (key.ctrl && input === 'z') {
250
+ if (process.platform !== 'win32') {
251
+ try { process.kill(process.pid, 'SIGTSTP'); } catch {}
252
+ }
253
+ return;
254
+ }
255
+ if (key.pageUp || key.pageDown) {
256
+ onNotice?.('Use terminal scrollback to view history.', C.muted);
257
+ return;
258
+ }
259
+ if (key.ctrl && input === 'r') {
260
+ setHistSearch(true);
261
+ setHistSearchQ('');
262
+ setHistSearchSel(0);
263
+ return;
264
+ }
265
+ if (key.ctrl && input === 's') { submit(value, { preserve: true }); return; }
266
+ if (key.ctrl && input === 'f') { onSearch?.(); return; }
267
+ if (key.shift && enter) {
268
+ snapshot('type');
269
+ setValue((v) => insertAt(v, cursor, '\n'));
270
+ setCursor((c) => c + 1);
271
+ return;
272
+ }
273
+ if (key.meta && enter) {
274
+ snapshot('type');
275
+ setValue((v) => insertAt(v, cursor, '\n'));
276
+ setCursor((c) => c + 1);
277
+ return;
278
+ }
125
279
  if (enter) {
126
- // In slash mode Enter runs the command; in mention mode it completes.
127
280
  if (menuOpen && slashMode) { submit(matches[sel].cmd); return; }
128
281
  if (menuOpen && mentionMode) { complete(); return; }
129
282
  submit(value);
130
283
  return;
131
284
  }
132
- if (key.tab && !key.shift) { complete(); return; }
133
-
134
- // ── Undo / redo ──
135
- if (key.ctrl && input === 'z') {
136
- const prev = undoRef.current.pop();
137
- if (prev) { redoRef.current.push({ value, cursor }); setValue(prev.value); setCursor(prev.cursor); lastMut.current = 'undo'; }
138
- return;
139
- }
140
- if (key.ctrl && (input === 'y' || (input === 'z' && key.shift))) {
141
- const nx = redoRef.current.pop();
142
- if (nx) { undoRef.current.push({ value, cursor }); setValue(nx.value); setCursor(nx.cursor); lastMut.current = 'redo'; }
285
+ if (key.escape && !value) {
286
+ onEmptyEsc?.();
143
287
  return;
144
288
  }
289
+ if (key.tab && !key.shift) { complete(); return; }
290
+ if (key.ctrl && input === 'y') { complete(); return; }
145
291
 
146
- // ── Menu / history navigation ──
147
292
  if (key.upArrow) {
148
293
  if (menuOpen) { setSel((i) => Math.max(0, i - 1)); return; }
149
294
  setHistory((h) => {
150
295
  if (!h.length) return h;
151
296
  const idx = histIdx === -1 ? h.length - 1 : Math.max(0, histIdx - 1);
152
- setHistIdx(idx); setText(h[idx]);
297
+ setHistIdx(idx);
298
+ setText(h[idx]);
153
299
  return h;
154
300
  });
155
301
  return;
@@ -159,70 +305,87 @@ export function Composer({ onSubmit, busy, width = 80 }) {
159
305
  setHistory((h) => {
160
306
  if (histIdx === -1) return h;
161
307
  const idx = histIdx + 1;
162
- if (idx >= h.length) { setHistIdx(-1); setText(''); } else { setHistIdx(idx); setText(h[idx]); }
308
+ if (idx >= h.length) {
309
+ setHistIdx(-1);
310
+ setText('');
311
+ } else {
312
+ setHistIdx(idx);
313
+ setText(h[idx]);
314
+ }
163
315
  return h;
164
316
  });
165
317
  return;
166
318
  }
167
319
 
168
- // ── Cursor movement ──
169
- if (key.leftArrow) { setCursor((c) => Math.max(0, c - 1)); return; }
320
+ if (key.leftArrow || (key.ctrl && input === 'b')) { setCursor((c) => Math.max(0, c - 1)); return; }
170
321
  if (key.rightArrow) {
171
- // At end of the active token, Right accepts the highlighted completion.
172
322
  if (menuOpen && cursor >= value.length) { complete(); return; }
173
323
  setCursor((c) => Math.min(value.length, c + 1));
174
324
  return;
175
325
  }
176
- if (key.ctrl && input === 'a') { setCursor(0); return; } // line start
177
- if (key.ctrl && input === 'e') { setCursor(value.length); return; } // line end
326
+ if (key.home || (key.ctrl && input === 'a')) { setCursor(0); return; }
327
+ if (key.end || (key.ctrl && input === 'e')) { setCursor(value.length); return; }
328
+ if (key.meta && key.leftArrow) { setCursor(wordLeft(value, cursor)); return; }
329
+ if (key.meta && key.rightArrow) { setCursor(wordRight(value, cursor)); return; }
178
330
 
179
- // ── Deletion ──
180
- if (key.backspace || key.delete) {
181
- if (cursor > 0) { snapshot('del'); setValue((v) => v.slice(0, cursor - 1) + v.slice(cursor)); setCursor((c) => c - 1); }
331
+ if (key.backspace || key.delete || (key.ctrl && input === 'h')) {
332
+ if (cursor > 0) {
333
+ snapshot('del');
334
+ setValue((v) => v.slice(0, cursor - 1) + v.slice(cursor));
335
+ setCursor((c) => c - 1);
336
+ }
182
337
  return;
183
338
  }
184
- if (key.ctrl && input === 'u') { snapshot('cut'); setValue(value.slice(cursor)); setCursor(0); return; } // to start
185
- if (key.ctrl && input === 'k') { snapshot('cut'); setValue(value.slice(0, cursor)); return; } // to end
186
- if (key.ctrl && input === 'w') { // prev word
187
- let i = cursor;
188
- while (i > 0 && value[i - 1] === ' ') i--;
189
- while (i > 0 && value[i - 1] !== ' ') i--;
339
+ if (key.ctrl && input === 'u') { snapshot('cut'); setValue(value.slice(cursor)); setCursor(0); return; }
340
+ if (key.ctrl && input === 'k') { snapshot('cut'); setValue(value.slice(0, cursor)); return; }
341
+ if (key.ctrl && input === 'w') {
342
+ const i = wordLeft(value, cursor);
190
343
  snapshot('cut');
191
- setValue(value.slice(0, i) + value.slice(cursor)); setCursor(i);
344
+ setValue(value.slice(0, i) + value.slice(cursor));
345
+ setCursor(i);
192
346
  return;
193
347
  }
194
348
 
195
- // ── Printable input & paste ──
196
349
  if (input && !key.ctrl && !key.meta) {
197
- // A multi-char chunk or one containing newlines/tabs is a non-bracketed
198
- // paste (terminal without bracketed-paste support): keep its newlines.
199
350
  if (input.length > 1 || /[\r\n\t]/.test(input)) { insertPaste(input); return; }
200
351
  const text = input.replace(/[\x00-\x1f\x7f]/g, '');
201
352
  if (!text) return;
202
353
  snapshot('type');
203
- setValue((v) => v.slice(0, cursor) + text + v.slice(cursor));
354
+ setValue((v) => insertAt(v, cursor, text));
204
355
  setCursor((c) => c + text.length);
205
356
  }
206
357
  });
207
358
 
208
359
  const before = value.slice(0, cursor);
209
360
  const rawAt = value[cursor];
210
- // When the cursor sits on a newline, show an inverse space and keep the line
211
- // break in `after` so the buffer still renders across multiple lines.
212
361
  const at = (rawAt === undefined || rawAt === '\n') ? ' ' : rawAt;
213
362
  const after = rawAt === '\n' ? '\n' + value.slice(cursor + 1) : value.slice(cursor + 1);
214
363
  const empty = value.length === 0;
215
364
  const sugTok = menuOpen && matches[sel] ? (slashMode ? matches[sel].cmd : matches[sel].tag) : '';
216
365
  const ghost = sugTok && sugTok.startsWith(token) && cursor >= value.length ? sugTok.slice(token.length) : '';
217
-
218
- // Prompt glyph: ❯ (success/green) idle; ⧖ (warning/amber) busy.
219
366
  const promptGlyph = busy ? glyphs.busy : glyphs.prompt;
220
367
  const promptColor = busy ? C.warning : C.success;
221
- // Ghost hint text when empty
222
- const ghostHint = busy ? 'Working…' : 'Enter @ to mention a device or / for commands…';
368
+ const ghostHint = busy ? 'Working…' : 'Enter @ to mention files or / for commands…';
369
+ const modePrefix = mode === 'plan'
370
+ ? '[plan] '
371
+ : mode === 'autopilot'
372
+ ? '[auto] '
373
+ : mode === 'shell'
374
+ ? '[shell] '
375
+ : '';
223
376
 
224
377
  return html`
225
378
  <${Box} flexDirection="column" width=${width}>
379
+ ${histSearch
380
+ ? html`<${Box} paddingX=${1} marginBottom=${0}>
381
+ <${Text} color=${C.accent}>reverse-search<//>
382
+ <${Text} color=${C.muted}>: ${histSearchQ || 'type to filter history'}<//>
383
+ ${historyMatches[histSearchSel]
384
+ ? html`<${Text} color=${C.white}> ${historyMatches[histSearchSel].slice(0, Math.max(20, width - 28))}<//>`
385
+ : html`<${Text} color=${C.error}> no matches<//>`}
386
+ <//>`
387
+ : null}
388
+
226
389
  ${menuOpen
227
390
  ? html`<${Box} flexDirection="column" paddingX=${1} marginBottom=${0}>
228
391
  ${slashMode ? null : html`<${Box}><${Text} color=${C.accent} bold>@ mention <//><${Text} color=${C.muted}>a device, customer or entity<//><//>`}
@@ -250,6 +413,7 @@ export function Composer({ onSubmit, busy, width = 80 }) {
250
413
 
251
414
  <${Box} width=${width} paddingX=${1}>
252
415
  <${Text} color=${promptColor} bold>${promptGlyph} <//>
416
+ ${modePrefix ? html`<${Text} color=${mode === 'shell' ? C.warning : C.accent} bold>${modePrefix}<//>` : null}
253
417
  ${empty
254
418
  ? html`<${Box} flexGrow=${1}>
255
419
  <${Text} color="white" inverse> <//>
@@ -259,5 +423,7 @@ export function Composer({ onSubmit, busy, width = 80 }) {
259
423
  <${Text}>${before}<//><${Text} inverse>${at}<//><${Text}>${after}<//><${Text} color=${C.muted}>${ghost}<//>
260
424
  <//>`}
261
425
  <//>
426
+
427
+ <${Text} color=${C.separator}>${'─'.repeat(Math.max(1, width))}<//>
262
428
  <//>`;
263
429
  }
package/src/tui/run.js CHANGED
@@ -1,27 +1,54 @@
1
- // Ink render entrypoint. Runs the TUI INLINE in the normal terminal buffer
2
- // (like GitHub Copilot CLI) — NOT the alternate screen — so the banner prints
3
- // once at the top and scrolls away, and the terminal's native scrollback keeps
4
- // the full conversation history. Completed transcript items are flushed to the
5
- // scrollback by Ink's <Static>; only the live area + input re-render in place.
1
+ // Ink render entrypoint. Runs the TUI INLINE in the normal terminal buffer.
6
2
  import { html } from './dom.js';
7
3
  import { render } from 'ink';
8
4
  import { App } from './app.js';
5
+ import { saveConfig } from '../config.js';
6
+
7
+ const LOGO = [
8
+ '██╗ ██████╗ ██████╗ ██████╗ ██╗██╗ ██████╗ ████████╗',
9
+ '██║██╔════╝██╔═══██╗██╔══██╗██║██║ ██╔═══██╗╚══██╔══╝',
10
+ '██║██║ ██║ ██║██████╔╝██║██║ ██║ ██║ ██║ ',
11
+ '██║██║ ██║ ██║██╔═══╝ ██║██║ ██║ ██║ ██║ ',
12
+ '██║╚██████╗╚██████╔╝██║ ██║███████╗╚██████╔╝ ██║ ',
13
+ '╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝ ╚═════╝ ╚═╝ ',
14
+ ];
15
+ const COLORS = ['35', '95', '94', '96', '97'];
16
+
17
+ async function animateSplash() {
18
+ if (!process.stdout.isTTY) return;
19
+ for (let frame = 0; frame < COLORS.length; frame++) {
20
+ const color = COLORS[frame];
21
+ const lines = LOGO.map((line, idx) => `\x1b[${Math.min(frame + idx + 1, 5) > 3 ? '1' : '2'};${color}m${line}\x1b[0m`).join('\n');
22
+ process.stdout.write(lines + '\n');
23
+ await new Promise((resolve) => setTimeout(resolve, 110));
24
+ if (frame !== COLORS.length - 1) process.stdout.write(`\x1b[${LOGO.length}A`);
25
+ }
26
+ process.stdout.write('\n');
27
+ }
9
28
 
10
29
  export async function runTui(cfg, opts = {}) {
11
- const { display = {}, onExternal, initialQuestion } = opts;
30
+ const { display = {}, onExternal, initialQuestion, banner = false } = opts;
12
31
  let externalAction;
32
+ const seen = Boolean(cfg?.tui?.bannerSeen);
33
+ const showAnimation = banner || !seen;
34
+
35
+ if (showAnimation) {
36
+ await animateSplash();
37
+ cfg.tui = { ...(cfg.tui || {}), bannerSeen: true };
38
+ try { saveConfig(cfg); } catch {}
39
+ }
13
40
 
14
41
  const instance = render(
15
42
  html`<${App}
16
43
  cfg=${cfg}
17
44
  display=${display}
18
45
  initialQuestion=${initialQuestion}
46
+ showBanner=${!showAnimation}
19
47
  onExternal=${(a) => { externalAction = a; onExternal?.(a); }}
20
48
  />`,
21
49
  { exitOnCtrlC: false },
22
50
  );
23
51
 
24
52
  await instance.waitUntilExit();
25
-
26
53
  return externalAction;
27
54
  }