@noobdemon/noob-cli 1.10.19 → 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/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 "node:readline";
10
- import { c } from "./ui.js";
9
+ import readline from 'node:readline';
10
+ import { c } from './ui.js';
11
11
 
12
- const ESC = "\x1b";
12
+ const ESC = '\x1b';
13
13
  const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
14
- const visLen = (s) => s.replace(ANSI_RE, "").length;
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] === "\x1b") {
21
+ if (text[i] === '\x1b') {
22
22
  const m = text.slice(i).match(/^\x1b\[[0-9;?]*[ -/]*[@-~]/);
23
- if (m) { i += m[0].length; continue; }
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 = "\x1b[0m";
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] = "" + trimmed + (hasAnsi ? RESET : "");
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) + "" : s;
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 !== "0";
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({ input: process.stdin, output: process.stdout, prompt: "" });
93
- r.on("line", (line) => {
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("close", () => {
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("SIGINT", () => onInterrupt?.());
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 = ""; // dòng output dở dang (chưa có '\n') hiện ngay trên thanh
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 = ""; // chuỗi phụ (elapsed + token) repl đẩy vào qua setMeta()
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 = "submit";
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 || "submit";
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("\n")[0] || "").trim();
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.map((x) => {
216
- if (x.paste === undefined) return x.c;
217
- const preview = pastePreview(x.paste);
218
- const label = preview ? `[pasted ${x.lines} lines: "${preview}"]` : `[pasted ${x.lines} lines]`;
219
- return c.dim(label);
220
- }).join("");
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
- // dùng để tính upBy — KHÔNG dùng totalRows (gồm cả top/menu
231
- // rows phía trên) vì sẽ kéo cursor lên quá cao, lần commit
232
- // kế tiếp ESC[J xóa luôn spinner+bar+dòng response trên cùng.
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 = " ".repeat(promptW);
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] === " ") pos++; // bỏ space đầu dòng sau
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("\n");
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(" · " + busyMeta);
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 = "".repeat(filled) + "".repeat(barW - filled);
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("") + c.dim(truncStr(current.text, cols() - 12))
333
- : c.ok("✓ hoàn thành!");
334
- const meta = busy && busyMeta ? c.dim(" · " + busyMeta) : "";
335
- const tail = busy ? c.dim(" · Ctrl+C") : "";
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(" · " + busyMeta) : "";
342
- const tail = busy ? c.dim(" · Ctrl+C để dừng") : "";
343
- const line = c.dim(spin + " ") + statusText + meta + tail;
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 ? " · " + busyMeta : "";
348
- const line = c.dim(spin + " " + (busyLabel || "đang chạy") + meta + " · Ctrl+C để dừng");
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 (sel ? c.accent("❯ ") : " ") + (sel ? c.user(m.name) : m.name) + (desc ? c.dim(" " + desc) : "");
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("\n"));
387
+ if (bar) r.push(...bar.split('\n'));
373
388
  return r;
374
389
  }
375
390
  function eraseSeq() {
376
- if (!drawn) return "\r";
377
- return "\r" + (prevRows > 1 ? `${ESC}[${prevRows - 1}A` : "") + `${ESC}[J`;
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 = "\r";
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("\n") + placeCursor() + `${ESC}[?25h`);
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("\n")) s += "\n";
403
- s += rs.join("\n") + placeCursor() + `${ESC}[?25h`;
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("\n")) !== -1) {
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("\n"));
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("\n").length;
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) === " ") 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) !== " ") 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) === " ") cur += 1;
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) !== " ") cur += 1;
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
- let s = carry + data;
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++) if (`${ESC}[201~`.startsWith(s.slice(s.length - k))) { safe = s.length - k; break; }
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, "\n").replace(/\r/g, "\n"));
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) { carry = rest; return; } // marker dở
536
- if (rest.startsWith(`${ESC}[200~`)) { inPaste = true; pasteAcc = ""; i += 6; continue; }
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)) { carry = rest; return; }
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(";"); // "\x1b[1;5C" → ["1","5"]
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 === "Z") onShiftTab?.(); // Shift+Tab = bật/tắt yolo
553
- else if (fin === "A" || fin === "B") {
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) { menuIdx = fin === "A" ? (menuIdx - 1 + menu.length) % menu.length : (menuIdx + 1) % menu.length; draw(); }
556
- else histNav(fin === "A" ? -1 : 1);
557
- } else if (fin === "C") { if (word) moveWordRight(); else if (cur < cells.length) cur += 1; draw(); } // →
558
- else if (fin === "D") { if (word) moveWordLeft(); else if (cur > 0) cur -= 1; draw(); } // ←
559
- else if (fin === "H") { cur = 0; draw(); } // Home
560
- else if (fin === "F") { cur = cells.length; draw(); } // End
561
- else if (fin === "~") {
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) { deleteForward(); refreshMenu(); draw(); } // Delete
564
- else if (n === 1 || n === 7) { cur = 0; draw(); } // Home
565
- else if (n === 4 || n === 8) { cur = cells.length; draw(); } // End
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 === "\r" || ch === "\n") {
571
- if (menu.length && menuFill === "insert") {
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 === "\r" && s[i + 1] === "\n") i++; // nuốt \n của \r\n
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 === "\x7f" || ch === "\b") { backspace(); refreshMenu(); draw(); i++; continue; } // Backspace
583
- if (ch === "\x03") { onInterrupt?.(); i++; continue; } // Ctrl+C
584
- if (ch === "\x0c") { console.clear(); draw(); i++; continue; } // Ctrl+L: clear screen
585
- if (ch === "\x01") { cur = 0; draw(); i++; continue; } // Ctrl+A → đầu dòng
586
- if (ch === "\x05") { cur = cells.length; draw(); i++; continue; } // Ctrl+E → cuối dòng
587
- if (ch === "\x15") { cells = cells.slice(cur); cur = 0; histPos = null; refreshMenu(); draw(); i++; continue; } // Ctrl+U: xoá tới đầu dòng
588
- if (ch === "\x0b") { cells = cells.slice(0, cur); histPos = null; refreshMenu(); draw(); i++; continue; } // Ctrl+K: xoá tới cuối dòng
589
- if (ch === "\x04") { if (!fullText()) onEOF?.(); else { deleteForward(); refreshMenu(); draw(); } i++; continue; } // Ctrl+D: rỗng=EOF, else xoá ký tự
590
- if (ch === "\t") { // Tab điền mục đang chọn (lệnh/file) + dấu cách, đóng menu
591
- if (menu.length) { acceptMenu(); draw(); }
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 < " ") { i++; continue; } // control khác — bỏ
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, "\n").replace(/\r/g, "\n");
610
- const firstNl = norm.indexOf("\n");
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("\n") ? norm.slice(0, -1) : norm; // bỏ 1 newline đuôi
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("utf8");
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("data", onData); // Shift+Tab (\x1b[Z) + mũi tên xử lý trong feedKeys
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(typeof chunk === "string" ? chunk : chunk.toString(typeof enc === "string" ? enc : "utf8"));
632
- if (typeof enc === "function") enc();
633
- else if (typeof cb === "function") cb();
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("\n") ? s : s + "\n");
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 = ""; // reset meta khi tắt busy để lượt sau không carry số cũ
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) {