@noobdemon/noob-cli 1.1.4 → 1.2.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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/src/repl.js +47 -123
  3. package/src/tui.js +312 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noobdemon/noob-cli",
3
- "version": "1.1.4",
3
+ "version": "1.2.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/src/repl.js CHANGED
@@ -1,7 +1,6 @@
1
- import readline from "node:readline";
2
1
  import process from "node:process";
3
- import ora from "ora";
4
2
  import chalk from "chalk";
3
+ import { createTui } from "./tui.js";
5
4
  import { runAgent } from "./agent.js";
6
5
  import { stream, usage, ApiError } from "./api.js";
7
6
  import { runTool, describe, DESTRUCTIVE } from "./tools.js";
@@ -40,106 +39,35 @@ export async function startRepl(opts = {}) {
40
39
  // nguyên reader đang chờ (người dùng chỉ thấy prompt hiện lại). Chỉ dừng thật
41
40
  // khi: /exit, Ctrl+C hai lần, hoặc EOF thật (stdin piped đã cạn / 'close' dồn
42
41
  // dập nghĩa là stdin đã mất).
43
- let rl;
44
- let closed = false; // đã ngừng đọc vĩnh viễn
45
- let exiting = false; // ta chủ động thoát (/exit, Ctrl+C ×2)
46
- let lastPrompt = ""; // áp lại sau khi dựng lại interface
47
- // Hàng đợi tin nhắn (giống Claude Code): khi model đang chạy, thêm câu hỏi
48
- // → xếp vào `pending`; xong turn, main loop tự lấy câu kế tiếp gửi lên. CHỈ
49
- // main loop tiêu thụ `pending`. Câu trả lời permission đi qua `waiter` riêng,
50
- // nên tin xếp hàng KHÔNG bị nhầm thành câu trả lời "cho phép?".
51
- const pending = [];
52
- let waiter = null; // resolver đang chờ một dòng tươi (prompt / permission)
53
- let closeAt = 0;
54
-
55
- function deliver(line) {
56
- if (waiter) {
57
- const w = waiter;
58
- waiter = null;
59
- w(line);
60
- return;
61
- }
62
- // Không ai đang hỏi → tin xếp hàng. Nếu đang chạy task → sẽ CHÈN cho AI ở
63
- // bước kế tiếp (steering). Nếu rảnh gửi như lượt mới khi tới phiên.
64
- pending.push(line);
65
- if (process.stdin.isTTY)
66
- console.log(abort ? c.user(" " + t.steerWillInject(truncate(line, 60))) : c.dim(" " + t.queued(pending.length, truncate(line, 60))));
67
- }
68
- function endInput() {
69
- closed = true;
70
- if (waiter) {
71
- const w = waiter;
72
- waiter = null;
73
- w(null);
74
- }
75
- }
76
- function buildRl() {
77
- const r = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: "" });
78
- r.on("line", deliver);
79
- // readline ở chế độ terminal phát 'SIGINT' của RIÊNG nó (không cho process
80
- // SIGINT chạy) — nối vào cùng một handler. interrupt() được hoist nên gọi
81
- // được dù khai báo phía dưới.
82
- r.on("SIGINT", () => interrupt());
83
- r.on("close", () => {
84
- if (exiting) return endInput(); // ta chủ động thoát
85
- // EOF THẬT: stdin đã end/destroy (Ctrl+Z trên Windows, Ctrl+D trên *nix),
86
- // hoặc stdin không phải TTY (piped) đã đọc hết. Chỉ lúc đó mới dừng.
87
- if (!process.stdin.isTTY || process.stdin.readableEnded || process.stdin.destroyed) {
88
- return endInput();
89
- }
90
- // 'close' BẤT THƯỜNG trên một TTY còn sống (tranh chấp console khi
91
- // paste/đa dòng, tiến trình con, v.v.) → KHÔNG BAO GIỜ thoát. Dựng lại
92
- // interface và hiện lại prompt; reader đang chờ vẫn được giữ nguyên. Nếu
93
- // close dồn dập thì hoãn 50ms để khỏi quay CPU — nhưng vẫn sống.
94
- const now = Date.now();
95
- const fast = now - closeAt < 50;
96
- closeAt = now;
97
- const rebuild = () => {
98
- rl = buildRl();
99
- if (lastPrompt) {
100
- rl.setPrompt(lastPrompt);
101
- rl.prompt();
102
- }
103
- };
104
- if (fast) setTimeout(rebuild, 50);
105
- else rebuild();
106
- });
107
- return r;
108
- }
109
- rl = buildRl();
42
+ let closed = false;
43
+ let exiting = false;
44
+ const pending = []; // hàng đợi tin nhắn (steering giữa task / lượt mới)
45
+
46
+ // Toàn bộ I/O qua `tui`: thanh nhập ghim đáy, output cuộn trên, paste nhiều
47
+ // dòng "[pasted N lines]" nhưng gửi đủ. Không phải TTY / NOOB_TUI=0 dumb
48
+ // mode = đọc dòng thường (an toàn pipe/CI/terminal lạ).
49
+ const tui = createTui({
50
+ onLine: (line) => {
51
+ // Submit khi KHÔNG read() đang chờ = tin xếp hàng. Đang chạy task → sẽ
52
+ // CHÈN cho AI ở bước kế tiếp (steering); rảnh → gửi như lượt mới.
53
+ pending.push(line);
54
+ tui.print(abort ? c.user(" " + t.steerWillInject(truncate(line, 60))) : c.dim(" " + t.queued(pending.length, truncate(line, 60))));
55
+ },
56
+ onInterrupt: () => interrupt(),
57
+ onEOF: () => {
58
+ closed = true;
59
+ },
60
+ onShiftTab: () => {
61
+ state.yolo = !state.yolo;
62
+ tui.print(state.yolo ? c.err(" " + t.yoloOn) : c.ok(" " + t.yoloOff));
63
+ tui.setPrompt(promptStr(false));
64
+ },
65
+ });
110
66
 
111
- function nextLine() {
112
- if (closed) return Promise.resolve(null);
113
- return new Promise((res) => (waiter = res));
114
- }
115
- // Dòng "tươi": dùng cho prompt chính VÀ cho permission. KHÔNG đụng tới
116
- // `pending` (hàng đợi tin nhắn) — chỉ main loop mới rút từ `pending`.
67
+ // Dòng "tươi" cho prompt chính VÀ permission. KHÔNG đụng `pending`.
117
68
  function ask(prompt) {
118
69
  if (closed) return Promise.resolve(null);
119
- lastPrompt = prompt;
120
- rl.setPrompt(prompt);
121
- rl.prompt();
122
- return nextLine();
123
- }
124
-
125
- // Shift+Tab — bật/tắt yolo nhanh (best-effort; gắn vào stdin nên sống qua mọi
126
- // lần dựng lại rl; bọc try/catch để không bao giờ làm hỏng input).
127
- if (process.stdin.isTTY) {
128
- try {
129
- readline.emitKeypressEvents(process.stdin);
130
- process.stdin.on("keypress", (_str, key) => {
131
- if (!key) return;
132
- // Ctrl+C dạng phím thô — ở raw mode, Ctrl+C KHÔNG tự thành SIGINT nữa.
133
- if (key.ctrl && key.name === "c") return interrupt();
134
- if (key.name !== "tab" || !key.shift) return;
135
- state.yolo = !state.yolo;
136
- console.log(state.yolo ? c.err(" " + t.yoloOn) : c.ok(" " + t.yoloOff));
137
- rl.setPrompt(promptStr(false)); // cập nhật dòng trạng thái ngay lập tức
138
- rl.prompt(true);
139
- });
140
- } catch {
141
- /* không có Shift+Tab cũng được — vẫn dùng /yolo */
142
- }
70
+ return tui.read(prompt);
143
71
  }
144
72
 
145
73
  // NOOB_DEBUG=1: vạch trần đường thoát thật sự (xác nhận/loại bỏ giả thuyết
@@ -179,7 +107,7 @@ export async function startRepl(opts = {}) {
179
107
  clearTimeout(sigintTimer);
180
108
  sigintTimer = null;
181
109
  }
182
- if (!closed) rl.prompt(true);
110
+ if (!closed) tui.setPrompt(promptStr(false));
183
111
  return;
184
112
  }
185
113
 
@@ -189,6 +117,7 @@ export async function startRepl(opts = {}) {
189
117
  exiting = true;
190
118
  persist();
191
119
  console.log(c.dim("\n " + t.bye));
120
+ tui.close(); // khôi phục terminal (raw mode/paste/stdout) trước khi thoát
192
121
  process.exit(0);
193
122
  }
194
123
  // lần 1 → vũ trang, đếm 1.5s
@@ -198,7 +127,7 @@ export async function startRepl(opts = {}) {
198
127
  sigintArmed = false;
199
128
  sigintTimer = null;
200
129
  }, 1500);
201
- if (!closed) rl.prompt(true);
130
+ if (!closed) tui.setPrompt(promptStr(false));
202
131
  }
203
132
  process.on("SIGINT", interrupt);
204
133
 
@@ -209,11 +138,11 @@ export async function startRepl(opts = {}) {
209
138
  process.on("uncaughtException", (err) => {
210
139
  if (abort) { abort.abort(); abort = null; }
211
140
  console.log(c.err("\n ✗ lỗi: " + (err?.message || err)));
212
- if (!closed) rl.prompt(true);
141
+ if (!closed) tui.setPrompt(promptStr(false));
213
142
  });
214
143
  process.on("unhandledRejection", (err) => {
215
144
  console.log(c.err("\n ✗ lỗi nền: " + (err?.message || err)));
216
- if (!closed) rl.prompt(true);
145
+ if (!closed) tui.setPrompt(promptStr(false));
217
146
  });
218
147
 
219
148
  // ── phiên (session): lưu lịch sử + resume giống Claude Code ───────────────
@@ -297,6 +226,7 @@ Cuối cùng: tóm tắt + danh sách sửa theo thứ tự ưu tiên. Thẳng t
297
226
  persist();
298
227
  }
299
228
 
229
+ tui.start();
300
230
  banner();
301
231
  printStatus(state);
302
232
  if (!config.apiKey) console.log("\n" + c.tool(" " + t.notLoggedIn) + "\n");
@@ -347,9 +277,8 @@ Cuối cùng: tóm tắt + danh sách sửa theo thứ tự ưu tiên. Thẳng t
347
277
  if (pending.length) {
348
278
  // Có tin xếp hàng → tự gửi câu kế tiếp (không chờ gõ).
349
279
  input = (pending.shift() ?? "").trim();
350
- if (process.stdin.isTTY && input) console.log(promptStr() + input);
351
280
  } else {
352
- const raw = await ask(promptStr());
281
+ const raw = await ask(promptStr(false));
353
282
  if (raw == null) break; // stdin fully closed and drained
354
283
  input = raw.trim();
355
284
  }
@@ -370,7 +299,7 @@ Cuối cùng: tóm tắt + danh sách sửa theo thứ tự ưu tiên. Thẳng t
370
299
  }
371
300
  }
372
301
  exiting = true;
373
- rl.close();
302
+ tui.close();
374
303
  process.exit(0);
375
304
 
376
305
  // ── turn handler ─────────────────────────────────────────────────────────
@@ -380,27 +309,22 @@ Cuối cùng: tóm tắt + danh sách sửa theo thứ tự ưu tiên. Thẳng t
380
309
  return;
381
310
  }
382
311
  abort = new AbortController();
383
- // discardStdin:false TỐI QUAN TRỌNG. Mặc định ora chiếm stdin (raw mode +
384
- // pause) để "nuốt" input khi quay. Trên Windows nó KHÔNG khôi phục sạch khi
385
- // stop → stdin chết → prompt "cho phép?" hiện ra rồi event loop cạn → CLI tự
386
- // out; và Ctrl+C không thành SIGINT (phải spam mới thoát). Tắt hẳn để ora
387
- // đừng đụng stdin — readline tự quản.
388
- const spinner = ora({ color: "magenta", spinner: "dots", discardStdin: false });
312
+ // spinner: status hiển thị qua tui (thanh dưới), tui tự animate khung xoay
389
313
  const t0 = Date.now();
390
314
  let timer = null;
391
315
  const tick = (label) => {
392
316
  const elapsed = ((Date.now() - t0) / 1000).toFixed(0);
393
- spinner.text = c.dim(`${label}… ${elapsed}s`);
317
+ tui.status(c.dim(`${label}… ${elapsed}s`));
394
318
  };
395
319
  const stopSpin = () => {
396
320
  if (timer) {
397
321
  clearInterval(timer);
398
322
  timer = null;
399
323
  }
400
- if (spinner.isSpinning) spinner.stop();
324
+ tui.status(null);
401
325
  };
402
326
  const startSpin = (label) => {
403
- if (!spinner.isSpinning) spinner.start();
327
+ // (tui hiện status khi tick gọi)
404
328
  if (!timer) timer = setInterval(() => tick(label), 200);
405
329
  };
406
330
 
@@ -415,7 +339,7 @@ Cuối cùng: tóm tắt + danh sách sửa theo thứ tự ưu tiên. Thẳng t
415
339
  message: text,
416
340
  signal: abort.signal,
417
341
  onStatus: (s) => {
418
- if (!printer.started) spinner.text = c.dim(" " + s);
342
+ if (!printer.started) tui.status(c.dim(" " + s));
419
343
  },
420
344
  onDelta: (d) => {
421
345
  stopSpin();
@@ -499,14 +423,14 @@ Cuối cùng: tóm tắt + danh sách sửa theo thứ tự ưu tiên. Thẳng t
499
423
  }
500
424
  }
501
425
 
502
- const sp = ora({ text: c.dim(" " + t.running), color: "yellow", discardStdin: false }).start();
426
+ tui.status(c.dim(" " + t.running));
503
427
  try {
504
428
  const result = await runTool(name, input);
505
- sp.stop();
429
+ tui.status(null);
506
430
  console.log(c.ok(" ✓ ") + c.dim(firstLine(result)));
507
431
  return { allow: true, result };
508
432
  } catch (err) {
509
- sp.stop();
433
+ tui.status(null);
510
434
  console.log(c.err(" ✗ " + err.message));
511
435
  return { allow: true, result: "ERROR: " + err.message };
512
436
  }
@@ -586,7 +510,7 @@ Cuối cùng: tóm tắt + danh sách sửa theo thứ tự ưu tiên. Thẳng t
586
510
  persist(); // giữ lại phiên cũ trên đĩa
587
511
  state.history = [];
588
512
  startFresh(); // phiên mới (phiên cũ vẫn resume được)
589
- console.clear();
513
+ if (!tui.tty) console.clear();
590
514
  banner();
591
515
  printStatus(state);
592
516
  console.log(c.dim(" " + t.ctxCleared + "\n"));
@@ -641,14 +565,14 @@ Cuối cùng: tóm tắt + danh sách sửa theo thứ tự ưu tiên. Thẳng t
641
565
 
642
566
  async function showUsage() {
643
567
  if (!config.apiKey) return console.log(c.tool(" " + t.notLoggedIn));
644
- const sp = ora({ text: c.dim(" ..."), color: "magenta", discardStdin: false }).start();
568
+ tui.status(c.dim(" ..."));
645
569
  try {
646
570
  const u = await usage();
647
- sp.stop();
571
+ tui.status(null);
648
572
  if (!u.ok) return printError(new ApiError(t.errInvalidKey, { code: u.error }));
649
573
  printUsage(u);
650
574
  } catch (err) {
651
- sp.stop();
575
+ tui.status(null);
652
576
  printError(err);
653
577
  }
654
578
  }
package/src/tui.js ADDED
@@ -0,0 +1,312 @@
1
+ // Bộ render kiểu TUI: thanh nhập GHIM ở đáy, output cuộn phía trên, không nhấp
2
+ // nháy. Hỗ trợ paste nhiều dòng → hiện "[pasted N lines]" nhưng GỬI toàn bộ nội
3
+ // dung. Hoạt động bằng cách "vá" process.stdout.write: mọi output (console.log,
4
+ // stream token, box…) tự được commit phía TRÊN thanh nhập — nên repl gần như
5
+ // không phải đổi chỗ in.
6
+ //
7
+ // Bật/tắt: TTY thật → chế độ giàu; không phải TTY hoặc NOOB_TUI=0 → chế độ "dumb"
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";
11
+
12
+ const ESC = "\x1b";
13
+ const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
14
+ const visLen = (s) => s.replace(ANSI_RE, "").length;
15
+ const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
16
+
17
+ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab } = {}) {
18
+ const tty = !!(process.stdout.isTTY && process.stdin.isTTY) && process.env.NOOB_TUI !== "0";
19
+ const cols = () => process.stdout.columns || 80;
20
+
21
+ // ── chế độ DUMB (không TTY / tắt TUI): readline thường ───────────────────
22
+ if (!tty) {
23
+ let rl = null;
24
+ let waiter = null;
25
+ const queue = [];
26
+ let closed = false;
27
+ const build = () => {
28
+ const r = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: "" });
29
+ r.on("line", (line) => {
30
+ if (waiter) {
31
+ const w = waiter;
32
+ waiter = null;
33
+ w(line);
34
+ } else if (onLine) onLine(line);
35
+ else queue.push(line);
36
+ });
37
+ r.on("close", () => {
38
+ closed = true;
39
+ if (waiter) {
40
+ const w = waiter;
41
+ waiter = null;
42
+ w(null);
43
+ }
44
+ onEOF?.();
45
+ });
46
+ return r;
47
+ };
48
+ return {
49
+ tty: false,
50
+ start() {
51
+ rl = build();
52
+ process.on("SIGINT", () => onInterrupt?.());
53
+ },
54
+ print() {},
55
+ status() {},
56
+ setPrompt() {},
57
+ read() {
58
+ if (queue.length) return Promise.resolve(queue.shift());
59
+ if (closed) return Promise.resolve(null);
60
+ return new Promise((res) => (waiter = res));
61
+ },
62
+ close() {
63
+ try {
64
+ rl?.close();
65
+ } catch {}
66
+ },
67
+ };
68
+ }
69
+
70
+ // ── chế độ GIÀU (TTY) ─────────────────────────────────────────────────────
71
+ const realWrite = process.stdout.write.bind(process.stdout);
72
+ const w = (s) => realWrite(s);
73
+
74
+ let liveOut = ""; // dòng output dở dang (chưa có '\n') hiện ngay trên thanh
75
+ let statusText = null; // text spinner khi đang nghĩ
76
+ let frame = 0;
77
+ let frameTimer = null;
78
+ let prevRows = 0;
79
+ let drawn = false;
80
+
81
+ let promptLabel = "";
82
+ let parts = []; // input: {type:'text',value} | {type:'paste',value,lines}
83
+ let waiter = null;
84
+ const queue = [];
85
+
86
+ // ----- input → chuỗi đầy đủ + bản hiển thị (paste = chip) -----
87
+ const fullText = () => parts.map((p) => p.value).join("");
88
+ const dispPlain = () => parts.map((p) => (p.type === "paste" ? `[pasted ${p.lines} lines]` : p.value)).join("");
89
+ const dispColored = () =>
90
+ parts.map((p) => (p.type === "paste" ? c.dim(`[pasted ${p.lines} lines]`) : p.value)).join("");
91
+
92
+ function renderBar() {
93
+ const budget = Math.max(4, cols() - visLen(promptLabel) - 1);
94
+ const plain = dispPlain();
95
+ if (plain.length <= budget) return promptLabel + dispColored();
96
+ return promptLabel + "…" + plain.slice(plain.length - (budget - 1)); // giữ đuôi (chỗ đang gõ)
97
+ }
98
+ function topRow() {
99
+ if (liveOut) return liveOut.slice(0, cols());
100
+ if (statusText) return c.dim(FRAMES[frame % FRAMES.length] + " ") + statusText;
101
+ return null;
102
+ }
103
+ function rows() {
104
+ const r = [];
105
+ const top = topRow();
106
+ if (top !== null) r.push(top);
107
+ r.push(renderBar());
108
+ return r;
109
+ }
110
+ function eraseSeq() {
111
+ if (!drawn) return "\r";
112
+ return "\r" + (prevRows > 1 ? `${ESC}[${prevRows - 1}A` : "") + `${ESC}[J`;
113
+ }
114
+ function draw() {
115
+ const rs = rows();
116
+ w(`${ESC}[?25l` + eraseSeq() + rs.join("\n") + `${ESC}[?25h`);
117
+ prevRows = rs.length;
118
+ drawn = true;
119
+ }
120
+ // In khối text VĨNH VIỄN phía trên thanh, rồi vẽ lại thanh.
121
+ function commit(block) {
122
+ const rs = rows();
123
+ let s = `${ESC}[?25l` + eraseSeq();
124
+ s += block;
125
+ if (!block.endsWith("\n")) s += "\n";
126
+ s += rs.join("\n") + `${ESC}[?25h`;
127
+ w(s);
128
+ prevRows = rs.length;
129
+ drawn = true;
130
+ }
131
+
132
+ // Mọi stdout đi qua đây: tách dòng hoàn chỉnh → commit; phần dở → liveOut.
133
+ function feedOutput(text) {
134
+ let buf = liveOut + text;
135
+ let nl;
136
+ const done = [];
137
+ while ((nl = buf.indexOf("\n")) !== -1) {
138
+ done.push(buf.slice(0, nl));
139
+ buf = buf.slice(nl + 1);
140
+ }
141
+ liveOut = buf;
142
+ if (done.length) commit(done.join("\n"));
143
+ else draw();
144
+ }
145
+
146
+ // ----- bàn phím: paste (bracketed) + heuristic chunk nhiều dòng -----
147
+ function pushText(str) {
148
+ if (!str) return;
149
+ const last = parts[parts.length - 1];
150
+ if (last && last.type === "text") last.value += str;
151
+ else parts.push({ type: "text", value: str });
152
+ }
153
+ function pushPaste(content) {
154
+ const lines = content.split("\n").length;
155
+ if (lines >= 2) parts.push({ type: "paste", value: content, lines });
156
+ else pushText(content); // paste 1 dòng = gõ thẳng
157
+ }
158
+ function backspace() {
159
+ const last = parts[parts.length - 1];
160
+ if (!last) return;
161
+ if (last.type === "paste") parts.pop();
162
+ else {
163
+ last.value = [...last.value].slice(0, -1).join("");
164
+ if (!last.value) parts.pop();
165
+ }
166
+ }
167
+ function submit() {
168
+ const full = fullText();
169
+ const echo = promptLabel + dispColored();
170
+ parts = [];
171
+ commit(echo); // hiện lại dòng đã gõ (paste = chip) vào scrollback
172
+ draw();
173
+ if (waiter) {
174
+ const wr = waiter;
175
+ waiter = null;
176
+ wr(full);
177
+ } else if (onLine) onLine(full);
178
+ else queue.push(full);
179
+ }
180
+
181
+ let carry = "";
182
+ let inPaste = false;
183
+ let pasteAcc = "";
184
+ function feedKeys(data) {
185
+ let s = carry + data;
186
+ carry = "";
187
+ let i = 0;
188
+ while (i < s.length) {
189
+ if (inPaste) {
190
+ const end = s.indexOf(`${ESC}[201~`, i);
191
+ if (end === -1) {
192
+ // giữ lại tối đa 5 ký tự cuối phòng marker bị cắt ngang chunk
193
+ let safe = s.length;
194
+ for (let k = 1; k <= 5; k++) if (`${ESC}[201~`.startsWith(s.slice(s.length - k))) { safe = s.length - k; break; }
195
+ pasteAcc += s.slice(i, safe);
196
+ carry = s.slice(safe);
197
+ return;
198
+ }
199
+ pasteAcc += s.slice(i, end);
200
+ inPaste = false;
201
+ pushPaste(pasteAcc.replace(/\r\n/g, "\n").replace(/\r/g, "\n"));
202
+ pasteAcc = "";
203
+ i = end + 6;
204
+ draw();
205
+ continue;
206
+ }
207
+ const rest = s.slice(i);
208
+ if (`${ESC}[200~`.startsWith(rest) && rest.length < 6) { carry = rest; return; } // marker dở
209
+ if (rest.startsWith(`${ESC}[200~`)) { inPaste = true; pasteAcc = ""; i += 6; continue; }
210
+ const ch = s[i];
211
+ if (ch === ESC) {
212
+ // esc seq (mũi tên…) — bỏ qua ở v1
213
+ const m = rest.match(/^\x1b\[[0-9;]*[A-Za-z~]/) || rest.match(/^\x1bO[A-Za-z]/);
214
+ if (m) { i += m[0].length; continue; }
215
+ if (rest.length < 3) { carry = rest; return; } // esc dở
216
+ i += 1;
217
+ continue;
218
+ }
219
+ if (ch === "\r" || ch === "\n") {
220
+ submit();
221
+ if (ch === "\r" && s[i + 1] === "\n") i++; // nuốt \n của \r\n
222
+ i++;
223
+ continue;
224
+ }
225
+ if (ch === "\x7f" || ch === "\b") { backspace(); draw(); i++; continue; }
226
+ if (ch === "\x03") { onInterrupt?.(); i++; continue; } // Ctrl+C
227
+ if (ch === "\x15") { parts = []; draw(); i++; continue; } // Ctrl+U xoá dòng
228
+ if (ch === "\x04") { if (!fullText()) onEOF?.(); i++; continue; } // Ctrl+D
229
+ if (ch === "\t") { i++; continue; } // tab thường — bỏ (shift+tab xử lý qua keypress)
230
+ if (ch < " ") { i++; continue; } // control khác — bỏ
231
+ // ký tự in được (kể cả UTF-8 vì stdin đã setEncoding utf8)
232
+ pushText(ch);
233
+ draw();
234
+ i++;
235
+ }
236
+ }
237
+
238
+ function onData(data) {
239
+ // Heuristic fallback cho terminal KHÔNG hỗ trợ bracketed-paste: một lần gõ
240
+ // 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
241
+ // newline KHÔNG nằm cuối (tức còn nội dung sau nó) → đó là PASTE nhiều dòng.
242
+ if (!inPaste && !data.includes(`${ESC}[200~`)) {
243
+ const norm = data.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
244
+ const firstNl = norm.indexOf("\n");
245
+ if (firstNl !== -1 && firstNl < norm.length - 1 && data.length > 1) {
246
+ const body = norm.endsWith("\n") ? norm.slice(0, -1) : norm; // bỏ 1 newline đuôi
247
+ pushPaste(body); // ≥2 dòng → chip; KHÔNG auto-submit (người dùng Enter để gửi)
248
+ draw();
249
+ return;
250
+ }
251
+ }
252
+ feedKeys(data);
253
+ }
254
+
255
+ return {
256
+ tty: true,
257
+ start() {
258
+ process.stdin.setEncoding("utf8");
259
+ if (process.stdin.setRawMode) process.stdin.setRawMode(true);
260
+ process.stdin.resume();
261
+ w(`${ESC}[?2004h`); // bật bracketed paste
262
+ process.stdin.on("data", onData);
263
+ // shift+tab: dùng keypress decoder song song (chỉ để bắt phím này)
264
+ try {
265
+ readline.emitKeypressEvents(process.stdin);
266
+ process.stdin.on("keypress", (_s, key) => {
267
+ if (key && key.name === "tab" && key.shift) onShiftTab?.();
268
+ });
269
+ } catch {}
270
+ // vá stdout: mọi output → commit phía trên thanh
271
+ process.stdout.write = (chunk, enc, cb) => {
272
+ feedOutput(typeof chunk === "string" ? chunk : chunk.toString(typeof enc === "string" ? enc : "utf8"));
273
+ if (typeof enc === "function") enc();
274
+ else if (typeof cb === "function") cb();
275
+ return true;
276
+ };
277
+ frameTimer = setInterval(() => {
278
+ if (statusText && !liveOut) {
279
+ frame++;
280
+ draw();
281
+ }
282
+ }, 90);
283
+ draw();
284
+ },
285
+ print(s = "") {
286
+ feedOutput(s.endsWith("\n") ? s : s + "\n");
287
+ },
288
+ status(text) {
289
+ statusText = text || null;
290
+ draw();
291
+ },
292
+ setPrompt(label) {
293
+ promptLabel = label || "";
294
+ draw();
295
+ },
296
+ read(label) {
297
+ if (label != null) promptLabel = label;
298
+ if (queue.length) return Promise.resolve(queue.shift());
299
+ draw();
300
+ return new Promise((res) => (waiter = res));
301
+ },
302
+ close() {
303
+ try {
304
+ if (frameTimer) clearInterval(frameTimer);
305
+ process.stdout.write = realWrite;
306
+ w(`${ESC}[?2004l${ESC}[?25h\n`);
307
+ if (process.stdin.setRawMode) process.stdin.setRawMode(false);
308
+ process.stdin.pause();
309
+ } catch {}
310
+ },
311
+ };
312
+ }