@noobdemon/noob-cli 1.13.1 → 1.13.3

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
@@ -13,6 +13,20 @@ import { c } from './ui.js';
13
13
  const ESC = '\x1b';
14
14
  const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
15
15
  const visLen = (s) => s.replace(ANSI_RE, '').length;
16
+ // Trích chuỗi mã ANSI còn "mở" tính tới cuối `text`: replay mọi SGR escape,
17
+ // reset (\x1b[0m / \x1b[m) xoá sạch, mã khác tích luỹ. Trả về chuỗi để TÁI MỞ
18
+ // màu ở đầu dòng wrap kế tiếp — chống mất màu khi soft-wrap cắt giữa vùng màu.
19
+ function openAnsi(text) {
20
+ let codes = [];
21
+ const re = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
22
+ let m;
23
+ while ((m = re.exec(text))) {
24
+ const seq = m[0];
25
+ if (seq === '\x1b[0m' || seq === '\x1b[m') codes = [];
26
+ else codes.push(seq);
27
+ }
28
+ return codes.join('');
29
+ }
16
30
  function readClipboardImageDataUrl() {
17
31
  if (process.platform !== 'win32') return null;
18
32
  try {
@@ -72,16 +86,20 @@ function softWrapLine(text, width) {
72
86
  if (visLen(text) <= width) return [close(text)];
73
87
  const lines = [];
74
88
  let remaining = text;
89
+ let carry = ''; // mã ANSI còn mở từ dòng trước → tái mở ở đầu dòng kế.
75
90
  while (remaining) {
76
- if (visLen(remaining) <= width) {
77
- lines.push(remaining);
91
+ const piece = carry + remaining;
92
+ if (visLen(piece) <= width) {
93
+ lines.push(piece);
78
94
  break;
79
95
  }
80
- let cutPos = findVisPos(remaining, width);
96
+ let cutPos = findVisPos(remaining, width - visLen(carry));
81
97
  const slice = remaining.slice(0, cutPos);
82
98
  const lastSpace = slice.lastIndexOf(' ');
83
99
  if (lastSpace > width * 0.3) cutPos = lastSpace;
84
- lines.push(remaining.slice(0, cutPos).trimEnd());
100
+ const head = remaining.slice(0, cutPos).trimEnd();
101
+ lines.push(carry + head);
102
+ carry = hasAnsi ? openAnsi(carry + head) : '';
85
103
  remaining = remaining.slice(cutPos).trimStart();
86
104
  }
87
105
  return lines.map(close);
@@ -97,17 +115,21 @@ function wrapText(text, width, maxLines) {
97
115
  // nằm ở đâu trong tổng stream.
98
116
  const lines = [];
99
117
  let remaining = text;
118
+ let carry = ''; // mã ANSI còn mở từ dòng trước → tái mở ở đầu dòng kế.
100
119
  while (remaining) {
101
- if (visLen(remaining) <= width) {
102
- lines.push(remaining);
120
+ const piece = carry + remaining;
121
+ if (visLen(piece) <= width) {
122
+ lines.push(piece);
103
123
  remaining = '';
104
124
  break;
105
125
  }
106
- let cutPos = findVisPos(remaining, width);
126
+ let cutPos = findVisPos(remaining, width - visLen(carry));
107
127
  const slice = remaining.slice(0, cutPos);
108
128
  const lastSpace = slice.lastIndexOf(' ');
109
129
  if (lastSpace > width * 0.3) cutPos = lastSpace;
110
- lines.push(remaining.slice(0, cutPos).trimEnd());
130
+ const head = remaining.slice(0, cutPos).trimEnd();
131
+ lines.push(carry + head);
132
+ carry = hasAnsi ? openAnsi(carry + head) : '';
111
133
  remaining = remaining.slice(cutPos).trimStart();
112
134
  }
113
135
  if (lines.length > maxLines) {
@@ -221,6 +243,8 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, onCtrlT, com
221
243
  let frameTimer = null;
222
244
  let prevRows = 0;
223
245
  let drawn = false;
246
+ let lastUpBy = 0; // số dòng con trỏ nằm TRÊN dòng cuối đã vẽ (placeCursor set);
247
+ // eraseSeq dùng để biết con trỏ đang ở đâu mà nhảy lên đúng dòng đầu.
224
248
 
225
249
  let promptLabel = '';
226
250
  // Thanh nhập = mảng "ô" + con trỏ. Mỗi ô là 1 ký tự {c} hoặc 1 khối dán
@@ -288,18 +312,30 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, onCtrlT, com
288
312
  return preview ? `[pasted ${x.lines} lines: "${preview}"]` : `[pasted ${x.lines} lines]`;
289
313
  };
290
314
  const fullText = () => cells.map(cellStr).join('');
291
- const coloredInput = () =>
292
- cells
293
- .map((x) => {
294
- if (x.image) return c.dim(imageLabel(x));
295
- if (x.paste === undefined) return x.c;
296
- const preview = pastePreview(x.paste);
297
- const label = preview
298
- ? `[pasted ${x.lines} lines: "${preview}"]`
299
- : `[pasted ${x.lines} lines]`;
300
- return c.dim(label);
301
- })
302
- .join('');
315
+ // Con trỏ ẢO: ô tại vị trí `cur` được đảo màu (reverse video \x1b[7m). Vẽ con
316
+ // trỏ trong CHÍNH text thay vì dựa con trỏ phần cứng → không phụ thuộc terminal
317
+ // có tôn trọng DECSCUSR / vẽ block-cursor đè sai ô hay không. cur == len → con
318
+ // trỏ sau ký tự cuối: đảo màu 1 space ở cuối.
319
+ const REV = `${ESC}[7m`;
320
+ const UNREV = `${ESC}[27m`;
321
+ const cellColored = (x) => {
322
+ if (x.image) return c.dim(imageLabel(x));
323
+ if (x.paste === undefined) return x.c;
324
+ const preview = pastePreview(x.paste);
325
+ const label = preview
326
+ ? `[pasted ${x.lines} lines: "${preview}"]`
327
+ : `[pasted ${x.lines} lines]`;
328
+ return c.dim(label);
329
+ };
330
+ const coloredInput = () => {
331
+ let out = '';
332
+ for (let i = 0; i < cells.length; i++) {
333
+ const rendered = cellColored(cells[i]);
334
+ out += i === cur ? `${REV}${rendered}${UNREV}` : rendered;
335
+ }
336
+ if (cur >= cells.length) out += `${REV} ${UNREV}`; // con trỏ ở cuối
337
+ return out;
338
+ };
303
339
 
304
340
  // Dựng thanh nhập + tính vị trí con trỏ trên màn (`cursorScreenCol` +
305
341
  // `cursorScreenRow`). Vừa khung → tô màu đầy đủ, 1 dòng. Tràn khung → soft-
@@ -308,6 +344,7 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, onCtrlT, com
308
344
  // thêm "…".
309
345
  let cursorScreenCol = 0;
310
346
  let cursorScreenRow = 0;
347
+ let trailingRows = 0; // số dòng trang trí SAU bar (thanh chắn dưới); placeCursor nhảy lên qua chúng
311
348
  let barRows = 1; // số dòng bar hiện tại; cập nhật bởi renderBar. placeCursor
312
349
  // dùng để tính upBy — KHÔNG dùng totalRows (gồm cả top/menu
313
350
  // rows phía trên) vì sẽ kéo cursor lên quá cao, lần commit
@@ -377,7 +414,21 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, onCtrlT, com
377
414
  cursorScreenCol = promptW + colInLine;
378
415
  barRows = lines.length;
379
416
 
380
- return lines.map((l, i) => (i === 0 ? promptLabel : indent) + l).join('\n');
417
+ // Con trỏ ẢO cho nhánh wrap: chèn ô đảo màu (reverse video) tại `colInLine`
418
+ // của dòng `curLine`. Con trỏ ở cuối ô (colInLine == lineLen) → đảo màu 1
419
+ // space append; giữa dòng → đảo màu ký tự tại đó. KHÔNG dùng con trỏ phần
420
+ // cứng (đã ẩn suốt phiên) nên nhánh này PHẢI tự vẽ con trỏ, nếu không wrap
421
+ // sẽ mất con trỏ.
422
+ return lines
423
+ .map((l, i) => {
424
+ let body = l;
425
+ if (i === curLine) {
426
+ if (colInLine >= l.length) body = l + `${REV} ${UNREV}`;
427
+ else body = l.slice(0, colInLine) + `${REV}${l[colInLine]}${UNREV}` + l.slice(colInLine + 1);
428
+ }
429
+ return (i === 0 ? promptLabel : indent) + body;
430
+ })
431
+ .join('\n');
381
432
  }
382
433
  function topRow() {
383
434
  if (liveOut) {
@@ -521,13 +572,29 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, onCtrlT, com
521
572
  if (top !== null) r.push(...top);
522
573
  for (const mr of menuRows()) r.push(mr);
523
574
  }
575
+ // Khung nhập: kẻ 2 thanh chắn (trên + dưới) quanh thanh nhập để khu gõ luôn
576
+ // rõ ràng, KỂ CẢ khi model đang chạy (rows() chạy cùng path busy/rảnh). Thanh
577
+ // chắn là dòng trang trí tĩnh — KHÔNG nằm trong barRows (renderBar set), nên
578
+ // placeCursor phải nhảy lên qua thanh chắn DƯỚI: cộng nó vào trailingRows.
579
+ const rule = c.dim('─'.repeat(Math.max(8, cols())));
524
580
  const bar = renderBar();
525
- if (bar) r.push(...bar.split('\n'));
581
+ if (bar) {
582
+ r.push(rule); // thanh chắn TRÊN (phía trên bar — không ảnh hưởng cursor)
583
+ r.push(...bar.split('\n'));
584
+ r.push(rule); // thanh chắn DƯỚI
585
+ trailingRows = 1; // 1 dòng sau bar → placeCursor nhảy lên thêm 1
586
+ } else {
587
+ trailingRows = 0;
588
+ }
526
589
  return r;
527
590
  }
528
591
  function eraseSeq() {
529
592
  if (!drawn) return '\r';
530
- return '\r' + (prevRows > 1 ? `${ESC}[${prevRows - 1}A` : '') + `${ESC}[J`;
593
+ // Sau lần vẽ trước, placeCursor đã đưa con trỏ về dòng nhập logic — nằm
594
+ // TRÊN dòng cuối cùng đã vẽ `lastUpBy` dòng (qua thanh chắn dưới + phần bar).
595
+ // Để về dòng ĐẦU rồi ESC[J xóa xuống, chỉ nhảy lên (prevRows-1) - lastUpBy.
596
+ const up = Math.max(0, prevRows - 1 - lastUpBy);
597
+ return '\r' + (up > 0 ? `${ESC}[${up}A` : '') + `${ESC}[J`;
531
598
  }
532
599
  // Sau khi vẽ xong các hàng, con trỏ đang ở CUỐI thanh (hàng cuối, cột cuối).
533
600
  // Đưa về đúng (row, col) của con trỏ logic: \r về cột 0 → đi lên `upBy` hàng
@@ -535,7 +602,11 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, onCtrlT, com
535
602
  // upBy dùng `barRows` (số dòng BAR, set bởi renderBar) — KHÔNG dùng totalRows
536
603
  // (gồm cả top/menu rows phía trên bar) vì sẽ kéo cursor lên quá cao.
537
604
  const placeCursor = () => {
538
- const upBy = barRows - 1 - cursorScreenRow;
605
+ // upBy: số dòng cần nhảy LÊN từ dòng cuối cùng đã vẽ về dòng con trỏ logic.
606
+ // Dòng cuối giờ là thanh chắn DƯỚI (trailingRows), nên cộng nó vào: nhảy qua
607
+ // thanh chắn rồi mới tính trong phạm vi bar như cũ.
608
+ const upBy = trailingRows + (barRows - 1 - cursorScreenRow);
609
+ lastUpBy = upBy; // ghi lại để eraseSeq lần sau biết con trỏ đang ở đâu
539
610
  let s = '\r';
540
611
  if (upBy > 0) s += `${ESC}[${upBy}A`;
541
612
  if (cursorScreenCol > 0) s += `${ESC}[${cursorScreenCol}C`;
@@ -543,7 +614,10 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, onCtrlT, com
543
614
  };
544
615
  function draw() {
545
616
  const rs = rows(); // rows() → renderBar() cập nhật cursorScreenRow/Col + barRows
546
- w(`${ESC}[?25l` + eraseSeq() + rs.join('\n') + placeCursor() + `${ESC}[?25h`);
617
+ // Con trỏ ảo (ô đảo màu trong coloredInput) con trỏ DUY NHẤT — giữ con trỏ
618
+ // phần cứng ẩn (?25l) suốt phiên, không bật lại ở cuối frame. placeCursor vẫn
619
+ // đưa con trỏ phần cứng về dòng nhập để eraseSeq lần sau tính đúng vùng xóa.
620
+ w(`${ESC}[?25l` + eraseSeq() + rs.join('\n') + placeCursor());
547
621
  prevRows = rs.length;
548
622
  drawn = true;
549
623
  }
@@ -560,7 +634,8 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, onCtrlT, com
560
634
  let s = `${ESC}[?25l` + eraseSeq();
561
635
  s += block;
562
636
  if (!block.endsWith('\n')) s += '\n';
563
- s += rs.join('\n') + placeCursor() + `${ESC}[?25h`;
637
+ // Con trỏ ảo con trỏ duy nhất — không bật con trỏ phần cứng ở cuối frame.
638
+ s += rs.join('\n') + placeCursor();
564
639
  w(s);
565
640
  prevRows = rs.length;
566
641
  drawn = true;
@@ -577,7 +652,27 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, onCtrlT, com
577
652
  }
578
653
  liveOut = buf;
579
654
  if (done.length) commit(done.join('\n'));
580
- else draw();
655
+ // Phần dở (chưa có \n) sống trong liveOut → topRow() đẩy nó lên TOP vùng vẽ
656
+ // lại mỗi frame. Nếu liveOut phình dài hơn 1 dòng terminal, wrapText cho nhiều
657
+ // dòng → tổng rows() cao dần, vượt chiều cao terminal → terminal CUỘN, prevRows
658
+ // không còn khớp vị trí vật lý con trỏ → eraseSeq lần kế xóa nhầm, bar (thanh
659
+ // chat) bị đẩy khuất/mất. Fix: khi liveOut tràn 1 dòng, FLUSH phần đã đầy dòng
660
+ // thành output vĩnh viễn (commit), chỉ giữ lại đuôi ngắn (< cols) trong liveOut.
661
+ else if (visLen(liveOut) > cols()) {
662
+ const w0 = cols();
663
+ const wrapped = softWrapLine(liveOut, w0);
664
+ // Giữ dòng cuối (đuôi đang gõ tiếp) trong liveOut, commit các dòng đã đầy.
665
+ const tail = wrapped[wrapped.length - 1];
666
+ const head = wrapped.slice(0, -1).join('\n');
667
+ if (head) {
668
+ liveOut = tail.replace(ANSI_RE, '');
669
+ commit(head);
670
+ } else {
671
+ draw();
672
+ }
673
+ } else {
674
+ draw();
675
+ }
581
676
  }
582
677
 
583
678
  // ----- bàn phím: paste (bracketed) + heuristic chunk nhiều dòng -----
@@ -941,7 +1036,9 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, onCtrlT, com
941
1036
  process.stdin.setEncoding('utf8');
942
1037
  if (process.stdin.setRawMode) process.stdin.setRawMode(true);
943
1038
  process.stdin.resume();
944
- w(`${ESC}[?2004h`); // bật bracketed paste
1039
+ // Bar cursor (DECSCUSR 6): vạch đứng hiển thị GIỮA hai ô, không đè màu lên
1040
+ // ký tự như block cursor → tránh ảo giác '|a' (block phủ ô trống sau text).
1041
+ w(`${ESC}[?2004h${ESC}[6 q`); // bật bracketed paste + bar cursor
945
1042
  process.stdin.on('data', onData); // Shift+Tab (\x1b[Z) + mũi tên xử lý trong feedKeys
946
1043
  // vá stdout: mọi output → commit phía trên thanh
947
1044
  process.stdout.write = (chunk, enc, cb) => {
@@ -1046,7 +1143,9 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, onCtrlT, com
1046
1143
  try {
1047
1144
  if (frameTimer) clearInterval(frameTimer);
1048
1145
  process.stdout.write = realWrite;
1049
- w(`${ESC}[?2004l${ESC}[?25h\n`);
1146
+ // Tắt bracketed paste + reset cursor style về mặc định (DECSCUSR 0) +
1147
+ // hiện con trỏ. Không reset → bar cursor dính lại ở shell sau khi thoát.
1148
+ w(`${ESC}[?2004l${ESC}[0 q${ESC}[?25h\n`);
1050
1149
  if (process.stdin.setRawMode) process.stdin.setRawMode(false);
1051
1150
  process.stdin.pause();
1052
1151
  } catch {}