@noobdemon/noob-cli 1.10.20 → 1.11.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/CHANGELOG.md +465 -0
- package/README.md +113 -27
- package/bin/noob.js +40 -27
- package/package.json +30 -2
- package/src/agent.js +213 -124
- package/src/api.js +105 -48
- package/src/config.js +11 -11
- package/src/i18n.js +171 -148
- package/src/memory.js +24 -13
- package/src/models.js +96 -46
- package/src/prompts/system.md +85 -0
- package/src/repl/complete.js +120 -0
- package/src/repl/todos.js +38 -0
- package/src/repl/ultra.js +62 -0
- package/src/repl/workflow-commands.js +238 -0
- package/src/repl.js +794 -769
- package/src/sessions.js +20 -20
- package/src/skills.js +13 -9
- package/src/subagent.js +3 -3
- package/src/tokens.js +37 -12
- package/src/tools.js +202 -121
- package/src/tui.js +240 -124
- package/src/ui.js +44 -44
- package/src/update.js +21 -21
- package/src/workflows-builtin.js +16 -14
- package/src/workflows.js +29 -27
package/src/tui.js
CHANGED
|
@@ -6,21 +6,24 @@
|
|
|
6
6
|
//
|
|
7
7
|
// Bật/tắt: TTY thật → chế độ giàu; không phải TTY hoặc NOOB_TUI=0 → chế độ "dumb"
|
|
8
8
|
// (đọc dòng đơn giản, in thẳng) để khỏi vỡ ở terminal lạ / pipe / CI.
|
|
9
|
-
import readline from
|
|
10
|
-
import { c } from
|
|
9
|
+
import readline from 'node:readline';
|
|
10
|
+
import { c } from './ui.js';
|
|
11
11
|
|
|
12
|
-
const ESC =
|
|
12
|
+
const ESC = '\x1b';
|
|
13
13
|
const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
|
|
14
|
-
const visLen = (s) => s.replace(ANSI_RE,
|
|
14
|
+
const visLen = (s) => s.replace(ANSI_RE, '').length;
|
|
15
15
|
// Trả index trong `text` mà tại đó vị trí VISUAL đạt `targetVis`. Bỏ qua toàn
|
|
16
16
|
// bộ ANSI escape sequence khi đếm. Dùng cho soft-wrap khi text có màu.
|
|
17
17
|
function findVisPos(text, targetVis) {
|
|
18
18
|
let vis = 0;
|
|
19
19
|
let i = 0;
|
|
20
20
|
while (i < text.length && vis < targetVis) {
|
|
21
|
-
if (text[i] ===
|
|
21
|
+
if (text[i] === '\x1b') {
|
|
22
22
|
const m = text.slice(i).match(/^\x1b\[[0-9;?]*[ -/]*[@-~]/);
|
|
23
|
-
if (m) {
|
|
23
|
+
if (m) {
|
|
24
|
+
i += m[0].length;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
24
27
|
}
|
|
25
28
|
vis++;
|
|
26
29
|
i++;
|
|
@@ -40,9 +43,9 @@ function findVisPos(text, targetVis) {
|
|
|
40
43
|
// `maxLines` dòng → dòng đầu page thêm "…" phía trước (và cắt 1 ký tự cuối để
|
|
41
44
|
// giữ nguyên độ rộng terminal, tránh re-wrap) để báo "có nội dung bị ẩn trên".
|
|
42
45
|
function wrapText(text, width, maxLines) {
|
|
43
|
-
if (!text) return [
|
|
46
|
+
if (!text) return [''];
|
|
44
47
|
const hasAnsi = /\x1b/.test(text);
|
|
45
|
-
const RESET =
|
|
48
|
+
const RESET = '\x1b[0m';
|
|
46
49
|
const close = (line) => (hasAnsi ? line + RESET : line);
|
|
47
50
|
if (visLen(text) <= width) return [close(text)];
|
|
48
51
|
// Wrap toàn bộ (không dừng ở maxLines) — cần đầy đủ để biết "page" hiện tại
|
|
@@ -52,12 +55,12 @@ function wrapText(text, width, maxLines) {
|
|
|
52
55
|
while (remaining) {
|
|
53
56
|
if (visLen(remaining) <= width) {
|
|
54
57
|
lines.push(remaining);
|
|
55
|
-
remaining =
|
|
58
|
+
remaining = '';
|
|
56
59
|
break;
|
|
57
60
|
}
|
|
58
61
|
let cutPos = findVisPos(remaining, width);
|
|
59
62
|
const slice = remaining.slice(0, cutPos);
|
|
60
|
-
const lastSpace = slice.lastIndexOf(
|
|
63
|
+
const lastSpace = slice.lastIndexOf(' ');
|
|
61
64
|
if (lastSpace > width * 0.3) cutPos = lastSpace;
|
|
62
65
|
lines.push(remaining.slice(0, cutPos).trimEnd());
|
|
63
66
|
remaining = remaining.slice(cutPos).trimStart();
|
|
@@ -66,20 +69,20 @@ function wrapText(text, width, maxLines) {
|
|
|
66
69
|
const visible = lines.slice(-maxLines).map(close);
|
|
67
70
|
const first = visible[0];
|
|
68
71
|
const body = first.endsWith(RESET) ? first.slice(0, -RESET.length) : first;
|
|
69
|
-
const trimmed = body.length ? body.slice(0, -1) :
|
|
70
|
-
visible[0] =
|
|
72
|
+
const trimmed = body.length ? body.slice(0, -1) : '';
|
|
73
|
+
visible[0] = '…' + trimmed + (hasAnsi ? RESET : '');
|
|
71
74
|
return visible;
|
|
72
75
|
}
|
|
73
76
|
return lines.map(close);
|
|
74
77
|
}
|
|
75
|
-
const FRAMES = [
|
|
78
|
+
const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
76
79
|
function truncStr(s, max) {
|
|
77
|
-
if (!s) return
|
|
78
|
-
return s.length > max ? s.slice(0, max - 1) +
|
|
80
|
+
if (!s) return '';
|
|
81
|
+
return s.length > max ? s.slice(0, max - 1) + '…' : s;
|
|
79
82
|
}
|
|
80
83
|
|
|
81
84
|
export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer } = {}) {
|
|
82
|
-
const tty = !!(process.stdout.isTTY && process.stdin.isTTY) && process.env.NOOB_TUI !==
|
|
85
|
+
const tty = !!(process.stdout.isTTY && process.stdin.isTTY) && process.env.NOOB_TUI !== '0';
|
|
83
86
|
const cols = () => process.stdout.columns || 80;
|
|
84
87
|
|
|
85
88
|
// ── chế độ DUMB (không TTY / tắt TUI): readline thường ───────────────────
|
|
@@ -89,8 +92,12 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
|
|
|
89
92
|
const queue = [];
|
|
90
93
|
let closed = false;
|
|
91
94
|
const build = () => {
|
|
92
|
-
const r = readline.createInterface({
|
|
93
|
-
|
|
95
|
+
const r = readline.createInterface({
|
|
96
|
+
input: process.stdin,
|
|
97
|
+
output: process.stdout,
|
|
98
|
+
prompt: '',
|
|
99
|
+
});
|
|
100
|
+
r.on('line', (line) => {
|
|
94
101
|
if (waiter) {
|
|
95
102
|
const w = waiter;
|
|
96
103
|
waiter = null;
|
|
@@ -98,7 +105,7 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
|
|
|
98
105
|
} else if (onLine) onLine(line);
|
|
99
106
|
else queue.push(line);
|
|
100
107
|
});
|
|
101
|
-
r.on(
|
|
108
|
+
r.on('close', () => {
|
|
102
109
|
closed = true;
|
|
103
110
|
if (waiter) {
|
|
104
111
|
const w = waiter;
|
|
@@ -113,7 +120,7 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
|
|
|
113
120
|
tty: false,
|
|
114
121
|
start() {
|
|
115
122
|
rl = build();
|
|
116
|
-
process.on(
|
|
123
|
+
process.on('SIGINT', () => onInterrupt?.());
|
|
117
124
|
},
|
|
118
125
|
print() {},
|
|
119
126
|
status() {},
|
|
@@ -137,21 +144,21 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
|
|
|
137
144
|
const realWrite = process.stdout.write.bind(process.stdout);
|
|
138
145
|
const w = (s) => realWrite(s);
|
|
139
146
|
|
|
140
|
-
let liveOut =
|
|
147
|
+
let liveOut = ''; // dòng output dở dang (chưa có '\n') hiện ngay trên thanh
|
|
141
148
|
let statusText = null; // text spinner khi đang nghĩ
|
|
142
149
|
// `busy` = một lượt/tool ĐANG chạy. Hiện status bar suốt lượt kể cả lúc
|
|
143
150
|
// statusText tạm trống (vd model ngừng phun token giữa các bước) → người dùng
|
|
144
151
|
// LUÔN thấy rõ "đang chạy", không bị tưởng treo.
|
|
145
152
|
let busy = false;
|
|
146
|
-
let busyLabel =
|
|
147
|
-
let busyMeta =
|
|
153
|
+
let busyLabel = '';
|
|
154
|
+
let busyMeta = ''; // chuỗi phụ (elapsed + token) repl đẩy vào qua setMeta()
|
|
148
155
|
let todos = []; // [{text, done}] — danh sách todo đang chạy, repl parse từ model output
|
|
149
156
|
let frame = 0;
|
|
150
157
|
let frameTimer = null;
|
|
151
158
|
let prevRows = 0;
|
|
152
159
|
let drawn = false;
|
|
153
160
|
|
|
154
|
-
let promptLabel =
|
|
161
|
+
let promptLabel = '';
|
|
155
162
|
// Thanh nhập = mảng "ô" + con trỏ. Mỗi ô là 1 ký tự {c} hoặc 1 khối dán
|
|
156
163
|
// {paste, lines} (hiện thành chip, sửa/xoá theo cả khối). `cur` ∈ [0, len] là
|
|
157
164
|
// vị trí con trỏ → hỗ trợ ←/→/Home/End/Delete, chèn & xoá GIỮA dòng.
|
|
@@ -171,13 +178,13 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
|
|
|
171
178
|
let menu = []; // [{name, desc}] khớp với những gì đang gõ
|
|
172
179
|
let menuIdx = 0; // mục đang chọn (mũi tên ↑/↓), Tab để điền
|
|
173
180
|
let menuStart = 0;
|
|
174
|
-
let menuFill =
|
|
181
|
+
let menuFill = 'submit';
|
|
175
182
|
function refreshMenu() {
|
|
176
183
|
const res = completer ? completer(fullText()) : null;
|
|
177
184
|
if (res && res.items && res.items.length) {
|
|
178
185
|
menu = res.items;
|
|
179
186
|
menuStart = res.start || 0;
|
|
180
|
-
menuFill = res.fill ||
|
|
187
|
+
menuFill = res.fill || 'submit';
|
|
181
188
|
} else {
|
|
182
189
|
menu = [];
|
|
183
190
|
menuStart = 0;
|
|
@@ -187,7 +194,7 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
|
|
|
187
194
|
// Điền mục đang chọn: thay token [menuStart…] bằng tên + 1 dấu cách, giữ phần đầu.
|
|
188
195
|
function acceptMenu() {
|
|
189
196
|
const prefix = fullText().slice(0, menuStart);
|
|
190
|
-
setInput(prefix + menu[menuIdx].name +
|
|
197
|
+
setInput(prefix + menu[menuIdx].name + ' ');
|
|
191
198
|
menu = [];
|
|
192
199
|
}
|
|
193
200
|
|
|
@@ -198,10 +205,10 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
|
|
|
198
205
|
// wrap được trong thanh input tiêu chuẩn.
|
|
199
206
|
const PASTE_PREVIEW_MAX = 24;
|
|
200
207
|
const pastePreview = (content) => {
|
|
201
|
-
const firstLine = (content.split(
|
|
202
|
-
if (!firstLine) return
|
|
208
|
+
const firstLine = (content.split('\n')[0] || '').trim();
|
|
209
|
+
if (!firstLine) return '';
|
|
203
210
|
return firstLine.length > PASTE_PREVIEW_MAX
|
|
204
|
-
? firstLine.slice(0, PASTE_PREVIEW_MAX - 1) +
|
|
211
|
+
? firstLine.slice(0, PASTE_PREVIEW_MAX - 1) + '…'
|
|
205
212
|
: firstLine;
|
|
206
213
|
};
|
|
207
214
|
const cellStr = (x) => (x.paste !== undefined ? x.paste : x.c);
|
|
@@ -210,14 +217,18 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
|
|
|
210
217
|
const preview = pastePreview(x.paste);
|
|
211
218
|
return preview ? `[pasted ${x.lines} lines: "${preview}"]` : `[pasted ${x.lines} lines]`;
|
|
212
219
|
};
|
|
213
|
-
const fullText = () => cells.map(cellStr).join(
|
|
220
|
+
const fullText = () => cells.map(cellStr).join('');
|
|
214
221
|
const coloredInput = () =>
|
|
215
|
-
cells
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
222
|
+
cells
|
|
223
|
+
.map((x) => {
|
|
224
|
+
if (x.paste === undefined) return x.c;
|
|
225
|
+
const preview = pastePreview(x.paste);
|
|
226
|
+
const label = preview
|
|
227
|
+
? `[pasted ${x.lines} lines: "${preview}"]`
|
|
228
|
+
: `[pasted ${x.lines} lines]`;
|
|
229
|
+
return c.dim(label);
|
|
230
|
+
})
|
|
231
|
+
.join('');
|
|
221
232
|
|
|
222
233
|
// Dựng thanh nhập + tính vị trí con trỏ trên màn (`cursorScreenCol` +
|
|
223
234
|
// `cursorScreenRow`). Vừa khung → tô màu đầy đủ, 1 dòng. Tràn khung → soft-
|
|
@@ -227,15 +238,15 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
|
|
|
227
238
|
let cursorScreenCol = 0;
|
|
228
239
|
let cursorScreenRow = 0;
|
|
229
240
|
let barRows = 1; // số dòng bar hiện tại; cập nhật bởi renderBar. placeCursor
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
241
|
+
// dùng để tính upBy — KHÔNG dùng totalRows (gồm cả top/menu
|
|
242
|
+
// rows phía trên) vì sẽ kéo cursor lên quá cao, lần commit
|
|
243
|
+
// kế tiếp ESC[J xóa luôn spinner+bar+dòng response trên cùng.
|
|
233
244
|
function renderBar() {
|
|
234
245
|
const promptW = visLen(promptLabel);
|
|
235
246
|
const budget = Math.max(4, cols() - promptW - 1);
|
|
236
|
-
const indent =
|
|
247
|
+
const indent = ' '.repeat(promptW);
|
|
237
248
|
const plains = cells.map(cellPlain);
|
|
238
|
-
const plain = plains.join(
|
|
249
|
+
const plain = plains.join('');
|
|
239
250
|
let total = 0;
|
|
240
251
|
for (const p of plains) total += p.length;
|
|
241
252
|
let curCharPos = 0;
|
|
@@ -265,18 +276,18 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
|
|
|
265
276
|
// Tìm space gần nhất trong slice [pos, pos+budget]; nếu > 30% budget → cắt
|
|
266
277
|
// tại đó. Không có space hợp lý → hard-slice theo budget.
|
|
267
278
|
const slice = plain.slice(pos, pos + budget);
|
|
268
|
-
const lastSpace = slice.lastIndexOf(
|
|
279
|
+
const lastSpace = slice.lastIndexOf(' ');
|
|
269
280
|
let cutLen = budget;
|
|
270
281
|
if (lastSpace > budget * 0.3) cutLen = lastSpace;
|
|
271
282
|
lines.push(slice.slice(0, cutLen).trimEnd());
|
|
272
283
|
pos += cutLen;
|
|
273
|
-
while (pos < total && plain[pos] ===
|
|
284
|
+
while (pos < total && plain[pos] === ' ') pos++; // bỏ space đầu dòng sau
|
|
274
285
|
}
|
|
275
286
|
// Nếu vẫn còn dư → đánh dấu dòng cuối bị truncate.
|
|
276
287
|
if (pos < total && lines.length) {
|
|
277
288
|
const last = lines.length - 1;
|
|
278
289
|
const lastLine = lines[last];
|
|
279
|
-
lines[last] = (lastLine.length >= budget ? lastLine.slice(0, -1) : lastLine) +
|
|
290
|
+
lines[last] = (lastLine.length >= budget ? lastLine.slice(0, -1) : lastLine) + '…';
|
|
280
291
|
}
|
|
281
292
|
|
|
282
293
|
// Map curCharPos → (line, colInLine).
|
|
@@ -295,7 +306,7 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
|
|
|
295
306
|
cursorScreenCol = promptW + colInLine;
|
|
296
307
|
barRows = lines.length;
|
|
297
308
|
|
|
298
|
-
return lines.map((l, i) => (i === 0 ? promptLabel : indent) + l).join(
|
|
309
|
+
return lines.map((l, i) => (i === 0 ? promptLabel : indent) + l).join('\n');
|
|
299
310
|
}
|
|
300
311
|
function topRow() {
|
|
301
312
|
if (liveOut) {
|
|
@@ -306,7 +317,7 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
|
|
|
306
317
|
// Note "token meter chèn vào prose" trong noob.md). Trường hợp wrap nhiều
|
|
307
318
|
// dòng: chỉ dòng đầu ghép meta (nếu vừa), các dòng sau prose thuần.
|
|
308
319
|
if (busy && busyMeta) {
|
|
309
|
-
const meta = c.dim(
|
|
320
|
+
const meta = c.dim(' · ' + busyMeta);
|
|
310
321
|
const metaLen = visLen(meta);
|
|
311
322
|
if (visLen(liveOut) + metaLen <= cols()) {
|
|
312
323
|
return [liveOut + meta];
|
|
@@ -325,27 +336,27 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
|
|
|
325
336
|
// Thanh progress: ████░░░░ 2/5 (40%)
|
|
326
337
|
const barW = Math.min(12, Math.floor(cols() * 0.15));
|
|
327
338
|
const filled = Math.round((done / total) * barW);
|
|
328
|
-
const bar =
|
|
339
|
+
const bar = '█'.repeat(filled) + '░'.repeat(barW - filled);
|
|
329
340
|
const progress = c.ok(`${done}/${total}`) + c.dim(` ${bar} ${pct}%`);
|
|
330
341
|
// Task đang làm: ▸ ...
|
|
331
342
|
const taskLine = current
|
|
332
|
-
? c.accent(
|
|
333
|
-
: c.ok(
|
|
334
|
-
const meta = busy && busyMeta ? c.dim(
|
|
335
|
-
const tail = busy ? c.dim(
|
|
343
|
+
? c.accent('▸ ') + c.dim(truncStr(current.text, cols() - 12))
|
|
344
|
+
: c.ok('✓ hoàn thành!');
|
|
345
|
+
const meta = busy && busyMeta ? c.dim(' · ' + busyMeta) : '';
|
|
346
|
+
const tail = busy ? c.dim(' · Ctrl+C') : '';
|
|
336
347
|
return [progress + meta + tail, taskLine];
|
|
337
348
|
}
|
|
338
349
|
// Khi busy, LUÔN hiện elapsed+tokens (busyMeta) cạnh statusText/busyLabel để
|
|
339
350
|
// người dùng thấy phiên đang sống — kể cả lúc model im giữa các bước.
|
|
340
351
|
if (statusText) {
|
|
341
|
-
const meta = busy && busyMeta ? c.dim(
|
|
342
|
-
const tail = busy ? c.dim(
|
|
343
|
-
const line = c.dim(spin +
|
|
352
|
+
const meta = busy && busyMeta ? c.dim(' · ' + busyMeta) : '';
|
|
353
|
+
const tail = busy ? c.dim(' · Ctrl+C để dừng') : '';
|
|
354
|
+
const line = c.dim(spin + ' ') + statusText + meta + tail;
|
|
344
355
|
return wrapText(line, cols(), 2);
|
|
345
356
|
}
|
|
346
357
|
if (busy) {
|
|
347
|
-
const meta = busyMeta ?
|
|
348
|
-
const line = c.dim(spin +
|
|
358
|
+
const meta = busyMeta ? ' · ' + busyMeta : '';
|
|
359
|
+
const line = c.dim(spin + ' ' + (busyLabel || 'đang chạy') + meta + ' · Ctrl+C để dừng');
|
|
349
360
|
return wrapText(line, cols(), 2);
|
|
350
361
|
}
|
|
351
362
|
return null;
|
|
@@ -358,9 +369,13 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
|
|
|
358
369
|
return menu.slice(start, start + MAXV).map((m, k) => {
|
|
359
370
|
const sel = start + k === menuIdx;
|
|
360
371
|
const budget = Math.max(0, cols() - 4 - m.name.length); // 2 (mũi) + 2 (cách)
|
|
361
|
-
let desc = m.desc ||
|
|
362
|
-
if (desc.length > budget) desc = desc.slice(0, Math.max(0, budget - 1)) +
|
|
363
|
-
return (
|
|
372
|
+
let desc = m.desc || '';
|
|
373
|
+
if (desc.length > budget) desc = desc.slice(0, Math.max(0, budget - 1)) + '…';
|
|
374
|
+
return (
|
|
375
|
+
(sel ? c.accent('❯ ') : ' ') +
|
|
376
|
+
(sel ? c.user(m.name) : m.name) +
|
|
377
|
+
(desc ? c.dim(' ' + desc) : '')
|
|
378
|
+
);
|
|
364
379
|
});
|
|
365
380
|
}
|
|
366
381
|
function rows() {
|
|
@@ -369,12 +384,12 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
|
|
|
369
384
|
if (top !== null) r.push(...top);
|
|
370
385
|
for (const mr of menuRows()) r.push(mr);
|
|
371
386
|
const bar = renderBar();
|
|
372
|
-
if (bar) r.push(...bar.split(
|
|
387
|
+
if (bar) r.push(...bar.split('\n'));
|
|
373
388
|
return r;
|
|
374
389
|
}
|
|
375
390
|
function eraseSeq() {
|
|
376
|
-
if (!drawn) return
|
|
377
|
-
return
|
|
391
|
+
if (!drawn) return '\r';
|
|
392
|
+
return '\r' + (prevRows > 1 ? `${ESC}[${prevRows - 1}A` : '') + `${ESC}[J`;
|
|
378
393
|
}
|
|
379
394
|
// Sau khi vẽ xong các hàng, con trỏ đang ở CUỐI thanh (hàng cuối, cột cuối).
|
|
380
395
|
// Đưa về đúng (row, col) của con trỏ logic: \r về cột 0 → đi lên `upBy` hàng
|
|
@@ -383,14 +398,14 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
|
|
|
383
398
|
// (gồm cả top/menu rows phía trên bar) vì sẽ kéo cursor lên quá cao.
|
|
384
399
|
const placeCursor = () => {
|
|
385
400
|
const upBy = barRows - 1 - cursorScreenRow;
|
|
386
|
-
let s =
|
|
401
|
+
let s = '\r';
|
|
387
402
|
if (upBy > 0) s += `${ESC}[${upBy}A`;
|
|
388
403
|
if (cursorScreenCol > 0) s += `${ESC}[${cursorScreenCol}C`;
|
|
389
404
|
return s;
|
|
390
405
|
};
|
|
391
406
|
function draw() {
|
|
392
407
|
const rs = rows(); // rows() → renderBar() cập nhật cursorScreenRow/Col + barRows
|
|
393
|
-
w(`${ESC}[?25l` + eraseSeq() + rs.join(
|
|
408
|
+
w(`${ESC}[?25l` + eraseSeq() + rs.join('\n') + placeCursor() + `${ESC}[?25h`);
|
|
394
409
|
prevRows = rs.length;
|
|
395
410
|
drawn = true;
|
|
396
411
|
}
|
|
@@ -399,8 +414,8 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
|
|
|
399
414
|
const rs = rows();
|
|
400
415
|
let s = `${ESC}[?25l` + eraseSeq();
|
|
401
416
|
s += block;
|
|
402
|
-
if (!block.endsWith(
|
|
403
|
-
s += rs.join(
|
|
417
|
+
if (!block.endsWith('\n')) s += '\n';
|
|
418
|
+
s += rs.join('\n') + placeCursor() + `${ESC}[?25h`;
|
|
404
419
|
w(s);
|
|
405
420
|
prevRows = rs.length;
|
|
406
421
|
drawn = true;
|
|
@@ -411,12 +426,12 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
|
|
|
411
426
|
let buf = liveOut + text;
|
|
412
427
|
let nl;
|
|
413
428
|
const done = [];
|
|
414
|
-
while ((nl = buf.indexOf(
|
|
429
|
+
while ((nl = buf.indexOf('\n')) !== -1) {
|
|
415
430
|
done.push(buf.slice(0, nl));
|
|
416
431
|
buf = buf.slice(nl + 1);
|
|
417
432
|
}
|
|
418
433
|
liveOut = buf;
|
|
419
|
-
if (done.length) commit(done.join(
|
|
434
|
+
if (done.length) commit(done.join('\n'));
|
|
420
435
|
else draw();
|
|
421
436
|
}
|
|
422
437
|
|
|
@@ -429,7 +444,7 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
|
|
|
429
444
|
histPos = null;
|
|
430
445
|
}
|
|
431
446
|
function pushPaste(content) {
|
|
432
|
-
const lines = content.split(
|
|
447
|
+
const lines = content.split('\n').length;
|
|
433
448
|
if (lines >= 2) {
|
|
434
449
|
cells.splice(cur, 0, { paste: content, lines });
|
|
435
450
|
cur += 1;
|
|
@@ -452,14 +467,14 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
|
|
|
452
467
|
// null = ô dán (coi như ranh giới từ); ngược lại trả ký tự của ô.
|
|
453
468
|
const charAt = (k) => (cells[k] && cells[k].paste === undefined ? cells[k].c : null);
|
|
454
469
|
function moveWordLeft() {
|
|
455
|
-
while (cur > 0 && charAt(cur - 1) ===
|
|
470
|
+
while (cur > 0 && charAt(cur - 1) === ' ') cur -= 1;
|
|
456
471
|
if (cur > 0 && charAt(cur - 1) === null) return void (cur -= 1); // qua 1 chip
|
|
457
|
-
while (cur > 0 && charAt(cur - 1) !== null && charAt(cur - 1) !==
|
|
472
|
+
while (cur > 0 && charAt(cur - 1) !== null && charAt(cur - 1) !== ' ') cur -= 1;
|
|
458
473
|
}
|
|
459
474
|
function moveWordRight() {
|
|
460
|
-
while (cur < cells.length && charAt(cur) ===
|
|
475
|
+
while (cur < cells.length && charAt(cur) === ' ') cur += 1;
|
|
461
476
|
if (cur < cells.length && charAt(cur) === null) return void (cur += 1);
|
|
462
|
-
while (cur < cells.length && charAt(cur) !== null && charAt(cur) !==
|
|
477
|
+
while (cur < cells.length && charAt(cur) !== null && charAt(cur) !== ' ') cur += 1;
|
|
463
478
|
}
|
|
464
479
|
function setInput(str) {
|
|
465
480
|
cells = [...str].map((ch) => ({ c: ch }));
|
|
@@ -505,12 +520,12 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
|
|
|
505
520
|
else queue.push(full);
|
|
506
521
|
}
|
|
507
522
|
|
|
508
|
-
let carry =
|
|
523
|
+
let carry = '';
|
|
509
524
|
let inPaste = false;
|
|
510
|
-
let pasteAcc =
|
|
525
|
+
let pasteAcc = '';
|
|
511
526
|
function feedKeys(data) {
|
|
512
|
-
|
|
513
|
-
carry =
|
|
527
|
+
const s = carry + data;
|
|
528
|
+
carry = '';
|
|
514
529
|
let i = 0;
|
|
515
530
|
while (i < s.length) {
|
|
516
531
|
if (inPaste) {
|
|
@@ -518,81 +533,180 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
|
|
|
518
533
|
if (end === -1) {
|
|
519
534
|
// giữ lại tối đa 5 ký tự cuối phòng marker bị cắt ngang chunk
|
|
520
535
|
let safe = s.length;
|
|
521
|
-
for (let k = 1; k <= 5; k++)
|
|
536
|
+
for (let k = 1; k <= 5; k++)
|
|
537
|
+
if (`${ESC}[201~`.startsWith(s.slice(s.length - k))) {
|
|
538
|
+
safe = s.length - k;
|
|
539
|
+
break;
|
|
540
|
+
}
|
|
522
541
|
pasteAcc += s.slice(i, safe);
|
|
523
542
|
carry = s.slice(safe);
|
|
524
543
|
return;
|
|
525
544
|
}
|
|
526
545
|
pasteAcc += s.slice(i, end);
|
|
527
546
|
inPaste = false;
|
|
528
|
-
pushPaste(pasteAcc.replace(/\r\n/g,
|
|
529
|
-
pasteAcc =
|
|
547
|
+
pushPaste(pasteAcc.replace(/\r\n/g, '\n').replace(/\r/g, '\n'));
|
|
548
|
+
pasteAcc = '';
|
|
530
549
|
i = end + 6;
|
|
531
550
|
draw();
|
|
532
551
|
continue;
|
|
533
552
|
}
|
|
534
553
|
const rest = s.slice(i);
|
|
535
|
-
if (`${ESC}[200~`.startsWith(rest) && rest.length < 6) {
|
|
536
|
-
|
|
554
|
+
if (`${ESC}[200~`.startsWith(rest) && rest.length < 6) {
|
|
555
|
+
carry = rest;
|
|
556
|
+
return;
|
|
557
|
+
} // marker dở
|
|
558
|
+
if (rest.startsWith(`${ESC}[200~`)) {
|
|
559
|
+
inPaste = true;
|
|
560
|
+
pasteAcc = '';
|
|
561
|
+
i += 6;
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
537
564
|
const ch = s[i];
|
|
538
565
|
if (ch === ESC) {
|
|
539
566
|
// Một chuỗi CSI hoàn chỉnh: \x1b[ … <chữ/~> hoặc SS3: \x1bO<chữ>.
|
|
540
567
|
const csi = rest.match(/^\x1b\[[0-9;]*[~A-Za-z]/) || rest.match(/^\x1bO[A-Za-z]/);
|
|
541
568
|
if (!csi) {
|
|
542
569
|
// Bị cắt ngang chunk → giữ lại chờ phần còn lại. Còn ESC đơn/lạ → bỏ.
|
|
543
|
-
if (/^\x1b(\[[0-9;]*|O)?$/.test(rest)) {
|
|
570
|
+
if (/^\x1b(\[[0-9;]*|O)?$/.test(rest)) {
|
|
571
|
+
carry = rest;
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
544
574
|
i += 1;
|
|
545
575
|
continue;
|
|
546
576
|
}
|
|
547
577
|
const seq = csi[0];
|
|
548
578
|
const fin = seq[seq.length - 1];
|
|
549
|
-
const params = seq.slice(2, -1).split(
|
|
579
|
+
const params = seq.slice(2, -1).split(';'); // "\x1b[1;5C" → ["1","5"]
|
|
550
580
|
const mod = parseInt(params[1], 10) || 0; // 5=Ctrl, 3=Alt, 2=Shift
|
|
551
581
|
const word = mod === 5 || mod === 3; // Ctrl/Alt + ←/→ = nhảy theo từ
|
|
552
|
-
if (fin ===
|
|
553
|
-
|
|
582
|
+
if (fin === 'Z')
|
|
583
|
+
onShiftTab?.(); // Shift+Tab = bật/tắt yolo
|
|
584
|
+
else if (fin === 'A' || fin === 'B') {
|
|
554
585
|
// ↑/↓: điều hướng menu gợi ý nếu đang mở, ngược lại gọi lại lịch sử.
|
|
555
|
-
if (menu.length) {
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
else if (fin ===
|
|
561
|
-
|
|
586
|
+
if (menu.length) {
|
|
587
|
+
menuIdx =
|
|
588
|
+
fin === 'A' ? (menuIdx - 1 + menu.length) % menu.length : (menuIdx + 1) % menu.length;
|
|
589
|
+
draw();
|
|
590
|
+
} else histNav(fin === 'A' ? -1 : 1);
|
|
591
|
+
} else if (fin === 'C') {
|
|
592
|
+
if (word) moveWordRight();
|
|
593
|
+
else if (cur < cells.length) cur += 1;
|
|
594
|
+
draw();
|
|
595
|
+
} // →
|
|
596
|
+
else if (fin === 'D') {
|
|
597
|
+
if (word) moveWordLeft();
|
|
598
|
+
else if (cur > 0) cur -= 1;
|
|
599
|
+
draw();
|
|
600
|
+
} // ←
|
|
601
|
+
else if (fin === 'H') {
|
|
602
|
+
cur = 0;
|
|
603
|
+
draw();
|
|
604
|
+
} // Home
|
|
605
|
+
else if (fin === 'F') {
|
|
606
|
+
cur = cells.length;
|
|
607
|
+
draw();
|
|
608
|
+
} // End
|
|
609
|
+
else if (fin === '~') {
|
|
562
610
|
const n = parseInt(params[0], 10);
|
|
563
|
-
if (n === 3) {
|
|
564
|
-
|
|
565
|
-
|
|
611
|
+
if (n === 3) {
|
|
612
|
+
deleteForward();
|
|
613
|
+
refreshMenu();
|
|
614
|
+
draw();
|
|
615
|
+
} // Delete
|
|
616
|
+
else if (n === 1 || n === 7) {
|
|
617
|
+
cur = 0;
|
|
618
|
+
draw();
|
|
619
|
+
} // Home
|
|
620
|
+
else if (n === 4 || n === 8) {
|
|
621
|
+
cur = cells.length;
|
|
622
|
+
draw();
|
|
623
|
+
} // End
|
|
566
624
|
}
|
|
567
625
|
i += seq.length;
|
|
568
626
|
continue;
|
|
569
627
|
}
|
|
570
|
-
if (ch ===
|
|
571
|
-
if (menu.length && menuFill ===
|
|
628
|
+
if (ch === '\r' || ch === '\n') {
|
|
629
|
+
if (menu.length && menuFill === 'insert') {
|
|
572
630
|
acceptMenu(); // @file: chỉ CHÈN vào dòng, KHÔNG gửi — cho gõ tiếp
|
|
573
631
|
draw();
|
|
574
632
|
} else {
|
|
575
633
|
if (menu.length) setInput(menu[menuIdx].name); // /lệnh: điền rồi gửi
|
|
576
634
|
submit();
|
|
577
635
|
}
|
|
578
|
-
if (ch ===
|
|
636
|
+
if (ch === '\r' && s[i + 1] === '\n') i++; // nuốt \n của \r\n
|
|
579
637
|
i++;
|
|
580
638
|
continue;
|
|
581
639
|
}
|
|
582
|
-
if (ch ===
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
if (ch ===
|
|
590
|
-
|
|
591
|
-
|
|
640
|
+
if (ch === '\x7f' || ch === '\b') {
|
|
641
|
+
backspace();
|
|
642
|
+
refreshMenu();
|
|
643
|
+
draw();
|
|
644
|
+
i++;
|
|
645
|
+
continue;
|
|
646
|
+
} // Backspace
|
|
647
|
+
if (ch === '\x03') {
|
|
648
|
+
onInterrupt?.();
|
|
649
|
+
i++;
|
|
650
|
+
continue;
|
|
651
|
+
} // Ctrl+C
|
|
652
|
+
if (ch === '\x0c') {
|
|
653
|
+
console.clear();
|
|
654
|
+
draw();
|
|
655
|
+
i++;
|
|
656
|
+
continue;
|
|
657
|
+
} // Ctrl+L: clear screen
|
|
658
|
+
if (ch === '\x01') {
|
|
659
|
+
cur = 0;
|
|
660
|
+
draw();
|
|
661
|
+
i++;
|
|
662
|
+
continue;
|
|
663
|
+
} // Ctrl+A → đầu dòng
|
|
664
|
+
if (ch === '\x05') {
|
|
665
|
+
cur = cells.length;
|
|
666
|
+
draw();
|
|
667
|
+
i++;
|
|
668
|
+
continue;
|
|
669
|
+
} // Ctrl+E → cuối dòng
|
|
670
|
+
if (ch === '\x15') {
|
|
671
|
+
cells = cells.slice(cur);
|
|
672
|
+
cur = 0;
|
|
673
|
+
histPos = null;
|
|
674
|
+
refreshMenu();
|
|
675
|
+
draw();
|
|
676
|
+
i++;
|
|
677
|
+
continue;
|
|
678
|
+
} // Ctrl+U: xoá tới đầu dòng
|
|
679
|
+
if (ch === '\x0b') {
|
|
680
|
+
cells = cells.slice(0, cur);
|
|
681
|
+
histPos = null;
|
|
682
|
+
refreshMenu();
|
|
683
|
+
draw();
|
|
684
|
+
i++;
|
|
685
|
+
continue;
|
|
686
|
+
} // Ctrl+K: xoá tới cuối dòng
|
|
687
|
+
if (ch === '\x04') {
|
|
688
|
+
if (!fullText()) onEOF?.();
|
|
689
|
+
else {
|
|
690
|
+
deleteForward();
|
|
691
|
+
refreshMenu();
|
|
692
|
+
draw();
|
|
693
|
+
}
|
|
694
|
+
i++;
|
|
695
|
+
continue;
|
|
696
|
+
} // Ctrl+D: rỗng=EOF, else xoá ký tự
|
|
697
|
+
if (ch === '\t') {
|
|
698
|
+
// Tab điền mục đang chọn (lệnh/file) + dấu cách, đóng menu
|
|
699
|
+
if (menu.length) {
|
|
700
|
+
acceptMenu();
|
|
701
|
+
draw();
|
|
702
|
+
}
|
|
592
703
|
i++;
|
|
593
704
|
continue;
|
|
594
705
|
}
|
|
595
|
-
if (ch <
|
|
706
|
+
if (ch < ' ') {
|
|
707
|
+
i++;
|
|
708
|
+
continue;
|
|
709
|
+
} // control khác — bỏ
|
|
596
710
|
// ký tự in được (kể cả UTF-8 vì stdin đã setEncoding utf8)
|
|
597
711
|
pushText(ch);
|
|
598
712
|
refreshMenu();
|
|
@@ -606,10 +720,10 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
|
|
|
606
720
|
// tay không bao giờ tạo ra "xuống dòng GIỮA chuỗi". Vậy nếu cả cục có một
|
|
607
721
|
// newline KHÔNG nằm cuối (tức còn nội dung sau nó) → đó là PASTE nhiều dòng.
|
|
608
722
|
if (!inPaste && !data.includes(`${ESC}[200~`)) {
|
|
609
|
-
const norm = data.replace(/\r\n/g,
|
|
610
|
-
const firstNl = norm.indexOf(
|
|
723
|
+
const norm = data.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
724
|
+
const firstNl = norm.indexOf('\n');
|
|
611
725
|
if (firstNl !== -1 && firstNl < norm.length - 1 && data.length > 1) {
|
|
612
|
-
const body = norm.endsWith(
|
|
726
|
+
const body = norm.endsWith('\n') ? norm.slice(0, -1) : norm; // bỏ 1 newline đuôi
|
|
613
727
|
pushPaste(body); // ≥2 dòng → chip; KHÔNG auto-submit (người dùng Enter để gửi)
|
|
614
728
|
draw();
|
|
615
729
|
return;
|
|
@@ -621,16 +735,18 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
|
|
|
621
735
|
return {
|
|
622
736
|
tty: true,
|
|
623
737
|
start() {
|
|
624
|
-
process.stdin.setEncoding(
|
|
738
|
+
process.stdin.setEncoding('utf8');
|
|
625
739
|
if (process.stdin.setRawMode) process.stdin.setRawMode(true);
|
|
626
740
|
process.stdin.resume();
|
|
627
741
|
w(`${ESC}[?2004h`); // bật bracketed paste
|
|
628
|
-
process.stdin.on(
|
|
742
|
+
process.stdin.on('data', onData); // Shift+Tab (\x1b[Z) + mũi tên xử lý trong feedKeys
|
|
629
743
|
// vá stdout: mọi output → commit phía trên thanh
|
|
630
744
|
process.stdout.write = (chunk, enc, cb) => {
|
|
631
|
-
feedOutput(
|
|
632
|
-
|
|
633
|
-
|
|
745
|
+
feedOutput(
|
|
746
|
+
typeof chunk === 'string' ? chunk : chunk.toString(typeof enc === 'string' ? enc : 'utf8')
|
|
747
|
+
);
|
|
748
|
+
if (typeof enc === 'function') enc();
|
|
749
|
+
else if (typeof cb === 'function') cb();
|
|
634
750
|
return true;
|
|
635
751
|
};
|
|
636
752
|
frameTimer = setInterval(() => {
|
|
@@ -641,8 +757,8 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
|
|
|
641
757
|
}, 90);
|
|
642
758
|
draw();
|
|
643
759
|
},
|
|
644
|
-
print(s =
|
|
645
|
-
feedOutput(s.endsWith(
|
|
760
|
+
print(s = '') {
|
|
761
|
+
feedOutput(s.endsWith('\n') ? s : s + '\n');
|
|
646
762
|
},
|
|
647
763
|
status(text) {
|
|
648
764
|
statusText = text || null;
|
|
@@ -651,13 +767,13 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
|
|
|
651
767
|
setBusy(on, label) {
|
|
652
768
|
busy = !!on;
|
|
653
769
|
if (label != null) busyLabel = label;
|
|
654
|
-
if (!on) busyMeta =
|
|
770
|
+
if (!on) busyMeta = ''; // reset meta khi tắt busy để lượt sau không carry số cũ
|
|
655
771
|
draw();
|
|
656
772
|
},
|
|
657
773
|
setMeta(meta) {
|
|
658
774
|
// repl bơm chuỗi phụ (vd: "12s · ↑1.2k ↓340 (1.5k)") để status bar hiện
|
|
659
775
|
// realtime kể cả khi model im giữa các bước.
|
|
660
|
-
const next = meta ||
|
|
776
|
+
const next = meta || '';
|
|
661
777
|
if (next === busyMeta) return;
|
|
662
778
|
busyMeta = next;
|
|
663
779
|
if (busy) draw();
|
|
@@ -669,7 +785,7 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
|
|
|
669
785
|
draw();
|
|
670
786
|
},
|
|
671
787
|
setPrompt(label) {
|
|
672
|
-
promptLabel = label ||
|
|
788
|
+
promptLabel = label || '';
|
|
673
789
|
draw();
|
|
674
790
|
},
|
|
675
791
|
read(label) {
|