ispbills-icli 8.5.3 → 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.
- package/bin/icli.js +2 -1
- package/package.json +1 -1
- package/src/commands.js +38 -35
- package/src/tui/app.js +508 -200
- package/src/tui/components.js +99 -48
- package/src/tui/composer.js +236 -72
- package/src/tui/run.js +34 -7
package/src/tui/composer.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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) {
|
|
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
|
-
|
|
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;
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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.
|
|
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);
|
|
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) {
|
|
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
|
-
|
|
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; }
|
|
177
|
-
if (key.ctrl && input === 'e') { setCursor(value.length); return; }
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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; }
|
|
185
|
-
if (key.ctrl && input === 'k') { snapshot('cut'); setValue(value.slice(0, cursor)); return; }
|
|
186
|
-
if (key.ctrl && input === 'w') {
|
|
187
|
-
|
|
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));
|
|
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
|
|
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 — per spec §7
|
|
222
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> <//>
|
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
|
}
|