@noobdemon/noob-cli 1.4.0 → 1.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noobdemon/noob-cli",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/src/agent.js CHANGED
@@ -23,7 +23,9 @@ Available tools:
23
23
  - list_dir {"path"?: str} — list a directory
24
24
  - glob {"pattern": str} — find files by glob (supports ** and *)
25
25
  - grep {"pattern": str, "path"?: str, "glob"?: str} — regex search file contents
26
- - run_command {"command": str} — run a shell command in the cwd
26
+ - run_command {"command": str, "timeout"?: int, "background"?: bool} — run a shell command in the cwd. A foreground command is killed after ~60s (override with "timeout" ms). For anything long-running or that never exits on its own — dev servers, watchers, \`python -m http.server\`, \`npm run dev\`, \`flask run\` — set "background": true: it starts the process, returns immediately, and keeps running WITHOUT blocking the next steps. Never start a server in the foreground (it will hang then be killed).
27
+ - bg_output {"id"?: int} — no id: list background processes + status; with id: show that process's captured output so far (poll this after starting a server to confirm it came up)
28
+ - kill_bg {"id": int} — stop a background process started with run_command background:true
27
29
 
28
30
  # Rules
29
31
  - GROUND TRUTH = the filesystem, NOT your memory of this chat. A file was created/changed ONLY if a write_file/edit_file TOOL RESULT confirms it (see the FILES CHANGED list). Saying "I created/updated X" in prose does NOT change any file — you must emit the tool call. If the user says a file is missing or asks its state, read_file/list_dir to check reality first; never claim a file "was reverted" or "should be there" from memory.
@@ -31,6 +33,10 @@ Available tools:
31
33
  - Make the smallest change that fully solves the task. Match the surrounding code style.
32
34
  - Prefer edit_file over write_file for existing files.
33
35
  - After changes, verify when sensible (run a build/test/lint command).
36
+ - Background process lifecycle — after starting one with background:true, POLL bg_output (give it a moment) until you actually have what you need: the server logged "listening"/a ready URL, the command finished (status shows exited), or the data you wanted appeared. Do NOT assume it worked. Then decide:
37
+ • Started only to read output / run a check / one-shot task → call kill_bg as soon as you have the result (and ALWAYS before you finish the turn). Never leave a throwaway background process running.
38
+ • A server/service the USER wants to keep using (they asked to "run the app/server") → leave it running, and tell the user it is up: its id and the URL/port, and that they can ask you to stop it when done (you will kill_bg it). It also stops automatically when noob exits.
39
+ • If bg_output shows it exited with a non-zero code or an error, treat it like a failed command: read the output and fix, don't silently move on.
34
40
  - Keep prose tight. Explain what you did and why, not how to use a tool.
35
41
  - JSON in the tool block must be valid: escape newlines as \\n inside string values.
36
42
  - LANGUAGE: Always write your prose answers to the user in Vietnamese (tiếng Việt), unless the user explicitly writes in another language. Keep code, file paths, commands, and tool JSON unchanged.
package/src/config.js CHANGED
@@ -55,6 +55,14 @@ export const config = {
55
55
  cache.model = id;
56
56
  write(cache);
57
57
  },
58
+ // yolo mặc định: bật sẵn yolo mỗi lần khởi động noob (lưu trên đĩa).
59
+ get yoloDefault() {
60
+ return !!cache.yolo;
61
+ },
62
+ setYolo(v) {
63
+ cache.yolo = !!v;
64
+ write(cache);
65
+ },
58
66
  get(k) {
59
67
  return cache[k];
60
68
  },
package/src/i18n.js CHANGED
@@ -57,6 +57,7 @@ export const t = {
57
57
  cmdSearch: "/search bật/tắt chế độ tìm web",
58
58
  cmdChat: "/chat quay lại chế độ chat thường",
59
59
  cmdYolo: "/yolo bật/tắt tự duyệt (hoặc nhấn Shift+Tab)",
60
+ cmdAutoYolo: "/auto-yolo lưu/bỏ yolo làm mặc định mỗi lần chạy (cần xác nhận)",
60
61
  cmdKarpathy: "/karpathy [path] rà soát code theo 4 nguyên tắc Karpathy (/kc)",
61
62
  cmdUltra: "/ultra <mục tiêu> tự hành: noob tự nghĩ & tự làm nhiệm vụ tới khi xong (/u)",
62
63
  cmdLearn: "/learn [ghi chú] chưng cất bài học của phiên vào noob.md",
@@ -73,10 +74,16 @@ export const t = {
73
74
  tip1: "• Mô tả việc cần làm; noob sẽ đọc/sửa file & chạy lệnh giúp bạn.",
74
75
  tip2: "• Đang chạy vẫn gõ tiếp được — tin sẽ xếp hàng & tự gửi khi model xong.",
75
76
  tip3: "• Shift+Tab: bật/tắt yolo nhanh. Ctrl+C 1 lần = dừng lượt, 2 lần = thoát.",
77
+ tip4: "• Gõ @ để gắn file (chỉ chỗ cho AI đọc). ←/→ Home/End sửa giữa dòng; ↑/↓ gọi lại lệnh cũ.",
76
78
 
77
79
  // misc
78
80
  yoloOn: "⚠ yolo BẬT — tự động duyệt mọi thao tác sửa file & chạy lệnh",
79
81
  yoloOff: "✓ yolo TẮT — sẽ hỏi trước khi sửa file & chạy lệnh",
82
+ autoYoloWarn: "⚠ yolo tự duyệt MỌI thao tác (sửa file/chạy lệnh) KHÔNG hỏi. Lưu làm mặc định = mỗi lần mở noob đều bật sẵn yolo.",
83
+ autoYoloConfirm: "Chắc chắn lưu yolo làm mặc định? gõ 'y' để xác nhận, phím khác để huỷ › ",
84
+ autoYoloOn: "⚡ Đã LƯU yolo làm mặc định — mọi phiên sau tự bật. Gõ /auto-yolo lần nữa để tắt.",
85
+ autoYoloOff: "✓ Đã bỏ yolo mặc định — phiên sau sẽ KHÔNG tự bật yolo (phiên này giữ nguyên).",
86
+ autoYoloCancel: "Huỷ — không thay đổi gì.",
80
87
  mergeOn: "Merge AI: BẬT",
81
88
  mergeOff: "Merge AI: TẮT",
82
89
  searchOn: "Tìm web: BẬT",
package/src/repl.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import process from "node:process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
2
4
  import chalk from "chalk";
3
5
  import { createTui } from "./tui.js";
4
6
  import { runAgent } from "./agent.js";
@@ -22,6 +24,7 @@ const SLASH = [
22
24
  { name: "/search", desc: "bật/tắt tìm web" },
23
25
  { name: "/chat", desc: "chế độ chat thường" },
24
26
  { name: "/yolo", desc: "bật/tắt tự duyệt" },
27
+ { name: "/auto-yolo", desc: "lưu yolo làm mặc định (cần xác nhận)" },
25
28
  { name: "/karpathy", desc: "rà soát code (Karpathy)" },
26
29
  { name: "/ultra", desc: "tự hành: tự nghĩ & làm nhiệm vụ" },
27
30
  { name: "/learn", desc: "chưng cất bài học vào noob.md" },
@@ -39,10 +42,80 @@ const SLASH = [
39
42
  { name: "/version", desc: "phiên bản" },
40
43
  { name: "/exit", desc: "thoát" },
41
44
  ];
42
- function completeSlash(text) {
43
- if (!text.startsWith("/") || /\s/.test(text)) return []; // chỉ gợi ý khi đang gõ tên lệnh
44
- const q = text.slice(1).toLowerCase();
45
- return SLASH.filter((cmd) => cmd.name.slice(1).toLowerCase().includes(q));
45
+ // Danh sách file trong cwd, cache 5s (gõ @ mỗi phím KHÔNG quét lại đĩa).
46
+ let fileCache = { at: 0, list: [] };
47
+ function allFiles() {
48
+ if (Date.now() - fileCache.at < 5000) return fileCache.list;
49
+ const out = [];
50
+ const root = process.cwd();
51
+ (function walk(dir, depth) {
52
+ if (out.length > 4000 || depth > 8) return;
53
+ let ents;
54
+ try {
55
+ ents = fs.readdirSync(dir, { withFileTypes: true });
56
+ } catch {
57
+ return;
58
+ }
59
+ for (const e of ents) {
60
+ if (e.name === "node_modules" || e.name.startsWith(".git")) continue;
61
+ const full = path.join(dir, e.name);
62
+ if (e.isDirectory()) walk(full, depth + 1);
63
+ else out.push(path.relative(root, full).split(path.sep).join("/"));
64
+ if (out.length > 4000) return;
65
+ }
66
+ })(root, 0);
67
+ fileCache = { at: Date.now(), list: out };
68
+ return out;
69
+ }
70
+ // Xếp hạng: tên file khớp đầu > đầu một đoạn path > chứa trong tên > chứa bất kỳ.
71
+ function fileMatches(frag) {
72
+ const all = allFiles();
73
+ const q = frag.toLowerCase();
74
+ if (!q) return all.slice(0, 12);
75
+ const scored = [];
76
+ for (const f of all) {
77
+ const lf = f.toLowerCase();
78
+ const base = lf.split("/").pop();
79
+ let s = -1;
80
+ if (base.startsWith(q)) s = 0;
81
+ else if (lf.includes("/" + q)) s = 1;
82
+ else if (base.includes(q)) s = 2;
83
+ else if (lf.includes(q)) s = 3;
84
+ if (s >= 0) scored.push([s, f]);
85
+ }
86
+ scored.sort((a, b) => a[0] - b[0] || a[1].length - b[1].length);
87
+ return scored.slice(0, 12).map((x) => x[1]);
88
+ }
89
+
90
+ // Gợi ý cho thanh nhập: /lệnh (điền-rồi-gửi) hoặc @file (chỉ chèn, gõ tiếp).
91
+ export function completeInput(text) {
92
+ if (text.startsWith("/") && !/\s/.test(text)) {
93
+ const q = text.slice(1).toLowerCase();
94
+ const items = SLASH.filter((cmd) => cmd.name.slice(1).toLowerCase().includes(q));
95
+ return items.length ? { items, start: 0, fill: "submit" } : null;
96
+ }
97
+ // @file: token CUỐI bắt đầu bằng @ (đầu dòng hoặc sau khoảng trắng).
98
+ const m = text.match(/(?:^|\s)@([^\s]*)$/);
99
+ if (m) {
100
+ const start = text.length - m[1].length - 1; // vị trí dấu '@'
101
+ const items = fileMatches(m[1]).map((p) => ({ name: "@" + p, desc: "file" }));
102
+ return items.length ? { items, start, fill: "insert" } : null;
103
+ }
104
+ return null;
105
+ }
106
+
107
+ // File thật được nhắc bằng @ trong tin nhắn → thêm chú thích để model đọc nhanh,
108
+ // đúng chỗ (bỏ qua @ không trỏ tới file có thật, vd @tên người).
109
+ export function mentionedFiles(text) {
110
+ const out = new Set();
111
+ const re = /(?:^|\s)@([^\s]+)/g;
112
+ let m;
113
+ while ((m = re.exec(text))) {
114
+ try {
115
+ if (fs.existsSync(path.resolve(process.cwd(), m[1]))) out.add(m[1]);
116
+ } catch {}
117
+ }
118
+ return [...out];
46
119
  }
47
120
 
48
121
  export async function startRepl(opts = {}) {
@@ -51,7 +124,7 @@ export async function startRepl(opts = {}) {
51
124
  mode: "chat", // chat | merge | search
52
125
  history: [],
53
126
  autoApprove: new Set(),
54
- yolo: !!opts.yolo,
127
+ yolo: !!opts.yolo || config.yoloDefault, // cờ --yolo HOẶC mặc định đã lưu (/auto-yolo)
55
128
  ultra: false, // chế độ tự hành (self-quest) đang chạy?
56
129
  };
57
130
 
@@ -97,7 +170,7 @@ export async function startRepl(opts = {}) {
97
170
  tui.print(state.yolo ? c.err(" " + t.yoloOn) : c.ok(" " + t.yoloOff));
98
171
  tui.setPrompt(promptStr(false));
99
172
  },
100
- completer: completeSlash,
173
+ completer: completeInput,
101
174
  });
102
175
 
103
176
  // Dòng "tươi" cho prompt chính VÀ permission. KHÔNG đụng `pending`.
@@ -272,19 +345,30 @@ Cuối cùng: tóm tắt + danh sách sửa theo thứ tự ưu tiên. Thẳng t
272
345
  // người dùng Ctrl+C. Mỗi vòng là một lượt agent đầy đủ (dùng lại handle()).
273
346
  const ULTRA_DONE = "<<ULTRA_DONE>>";
274
347
  const MAX_QUESTS = 40;
348
+ // Chỉ coi là HOÀN THÀNH khi token nằm ở CUỐI câu trả lời (dòng riêng) — tránh
349
+ // bắt nhầm khi model chỉ NHẮC tới token giữa văn xuôi. Không bao giờ chấp nhận
350
+ // ở lượt lập kế hoạch (xem vòng lặp bên dưới).
351
+ const ultraIsDone = (a) => a.trimEnd().endsWith(ULTRA_DONE);
275
352
  const ultraStart = (goal) => `# CHẾ ĐỘ ULTRA (tự hành)
276
353
  Mục tiêu tổng: ${goal}
277
354
 
278
- Bạn sẽ TỰ lập kế hoạch và TỰ thực hiện từng bước tới khi hoàn thành, KHÔNG chờ người dùng xác nhận giữa chừng.
279
- - Mỗi lượt: chọn nhiệm vụ con hợp lý kế tiếp, làm bằng tool (đọc/sửa file, chạy lệnh).
280
- - Sau khi làm, tự kiểm chứng (build/test) khi hợp lý.
281
- - Ghi tiến độ + điều học được vào noob.md để không quên giữa các bước.
282
- - Khi CHỈ KHI toàn bộ mục tiêu đã xong đã kiểm chứng, kết thúc câu trả lời bằng đúng token ${ULTRA_DONE}.
283
- - Gặp việc nguy hiểm/không thể đảo ngược hoặc thật sự DỪNG, hỏi 1 câu ràng (đừng phát ${ULTRA_DONE}).
284
- Bắt đầu bằng kế hoạch ngắn (3–7 gạch đầu dòng) rồi làm bước đầu tiên.`;
285
- const ultraContinue = (goal) => `Tiếp tục chế độ ULTRA mục tiêu: ${goal}
286
- Tự đánh giá còn thiếu gì, chọn nhiệm vụ con kế tiếp và làm tiếp bằng tool. Cập nhật noob.md nếu học được điều mới.
287
- Khi toàn bộ mục tiêu đã xong đã kiểm chứng, kết thúc bằng token ${ULTRA_DONE}.`;
355
+ Bạn TỰ lập kế hoạch và TỰ làm từng bước tới khi HOÀN THÀNH THẬT, không chờ người dùng xác nhận giữa chừng.
356
+ LƯỢT NÀY (lập kế hoạch + khởi động):
357
+ - Viết kế hoạch ngắn 3–7 gạch đầu dòng.
358
+ - Rồi BẮT TAY làm bước đầu bằng tool (đọc/sửa file, chạy lệnh) không nói suông.
359
+ - TUYỆT ĐỐI KHÔNG phát token ${ULTRA_DONE} ở lượt này, dù mục tiêu trông nhỏ. Lượt lập kế hoạch KHÔNG bao giờ lượt kết thúc.
360
+ Nguyên tắc xuyên suốt: chỉ KẾT QUẢ TOOL mới tính "đã làm"; nói "đã xong/đã sửa" trong văn xuôi KHÔNG có tool result thì KHÔNG tính.`;
361
+ const ultraContinue = (goal) => `Tiếp tục ULTRA mục tiêu: ${goal}
362
+ Tự đánh giá: còn THIẾU gì để đạt mục tiêu? Chọn 1 nhiệm vụ con kế tiếp và LÀM bằng tool (đừng chỉ tả). Cập nhật noob.md nếu học được điều mới.
363
+
364
+ ĐIỀU KIỆN KẾT THÚC chỉ được dừng khi ĐỦ CẢ 3; thiếu bất kỳ điều nào thì LÀM TIẾP, đừng dừng:
365
+ 1. Mọi phần của mục tiêu đã thực sự làm xong, có TOOL RESULT xác nhận (đối chiếu mục FILES CHANGED) — không chỉ nói trong văn xuôi.
366
+ 2. ĐÃ KIỂM CHỨNG: chạy build/test/lint hoặc chạy thử phần vừa làm bằng run_command và ĐỌC output thấy ĐẠT. Nếu dự án không có cách kiểm chứng tự động → nêu rõ đã kiểm tra bằng cách nào.
367
+ 3. Đã rà lại, không còn việc dở hay lỗi.
368
+
369
+ • ĐỦ cả 3 → viết tóm tắt NGẮN việc đã làm + BẰNG CHỨNG kiểm chứng (lệnh đã chạy & kết quả thật), rồi đặt token ${ULTRA_DONE} TRÊN MỘT DÒNG RIÊNG ở CUỐI CÙNG.
370
+ • CHƯA đủ (còn việc, hoặc chưa chạy kiểm chứng) → ĐỪNG phát token, tiếp tục bước kế.
371
+ • Gặp việc nguy hiểm/không đảo ngược hoặc thật sự bí → DỪNG hỏi 1 câu rõ ràng (đừng phát token).`;
288
372
 
289
373
  async function runUltra(goal) {
290
374
  if (!config.apiKey) return console.log(c.tool(" " + t.notLoggedIn));
@@ -295,16 +379,18 @@ Khi toàn bộ mục tiêu đã xong và đã kiểm chứng, kết thúc bằng
295
379
  let answer = await handle(ultraStart(goal));
296
380
  persist();
297
381
  let i = 0;
382
+ // Lượt đầu = lập kế hoạch → KHÔNG xét hoàn thành. Mỗi vòng sau là một lượt
383
+ // "tiếp tục" có cổng kiểm chứng; chỉ dừng khi token nằm ở CUỐI câu trả lời.
298
384
  while (state.ultra && i < MAX_QUESTS) {
299
385
  if (!answer) break; // lượt bị ngắt/ lỗi → dừng tự hành, đừng quay vô ích
300
- if (answer.includes(ULTRA_DONE)) {
301
- console.log(c.ok(" ✓ " + t.ultraDone));
302
- break;
303
- }
304
386
  i++;
305
387
  console.log(c.accent(" ↻ " + t.ultraQuest(i)));
306
388
  answer = await handle(ultraContinue(goal));
307
389
  persist();
390
+ if (answer && ultraIsDone(answer)) {
391
+ console.log(c.ok(" ✓ " + t.ultraDone));
392
+ break;
393
+ }
308
394
  }
309
395
  if (state.ultra && i >= MAX_QUESTS) console.log(c.tool(" " + t.ultraMax));
310
396
  state.ultra = false;
@@ -332,6 +418,25 @@ Khi toàn bộ mục tiêu đã xong và đã kiểm chứng, kết thúc bằng
332
418
  console.log(c.dim(" " + memoryPath() + t.memoryStat(mem.split("\n").length)));
333
419
  }
334
420
 
421
+ // /auto-yolo — lưu/bỏ yolo làm MẶC ĐỊNH (mỗi lần mở noob tự bật). Vì yolo tự
422
+ // duyệt mọi thao tác, BẬT phải xác nhận lần 2 bằng cách gõ 'y'. TẮT thì không.
423
+ async function toggleAutoYolo() {
424
+ if (config.yoloDefault) {
425
+ config.setYolo(false);
426
+ return console.log(c.ok(" " + t.autoYoloOff));
427
+ }
428
+ console.log(c.err(" " + t.autoYoloWarn));
429
+ const ans = ((await ask(c.tool(" " + t.autoYoloConfirm))) ?? "").trim().toLowerCase();
430
+ if (ans === "y" || ans === "yes" || ans === "có") {
431
+ config.setYolo(true);
432
+ state.yolo = true; // áp dụng ngay cho phiên hiện tại
433
+ if (!closed) tui.setPrompt(promptStr(false));
434
+ console.log(c.err(" " + t.autoYoloOn));
435
+ } else {
436
+ console.log(c.dim(" " + t.autoYoloCancel));
437
+ }
438
+ }
439
+
335
440
  tui.start();
336
441
  banner();
337
442
  printStatus(state);
@@ -418,6 +523,7 @@ Khi toàn bộ mục tiêu đã xong và đã kiểm chứng, kết thúc bằng
418
523
  return;
419
524
  }
420
525
  abort = new AbortController();
526
+ tui.setBusy(true, t.thinking); // chỉ báo "đang chạy" bền suốt lượt (kể cả lúc model im)
421
527
  // spinner: status hiển thị qua tui (thanh dưới), tui tự animate khung xoay
422
528
  const t0 = Date.now();
423
529
  let timer = null;
@@ -461,7 +567,11 @@ Khi toàn bộ mục tiêu đã xong và đã kiểm chứng, kết thúc bằng
461
567
  return;
462
568
  }
463
569
 
464
- state.history.push({ role: "user", content: text });
570
+ const files = mentionedFiles(text);
571
+ const content = files.length
572
+ ? text + `\n\n[File người dùng nhắc tới bằng @: ${files.join(", ")} — đọc bằng read_file nếu cần.]`
573
+ : text;
574
+ state.history.push({ role: "user", content });
465
575
  if (process.stdin.isTTY && !state.steerHintShown) {
466
576
  console.log(c.dim(" " + t.steerHint));
467
577
  state.steerHintShown = true;
@@ -513,6 +623,7 @@ Khi toàn bộ mục tiêu đã xong và đã kiểm chứng, kết thúc bằng
513
623
  printError(err);
514
624
  } finally {
515
625
  abort = null;
626
+ tui.setBusy(false);
516
627
  }
517
628
  }
518
629
 
@@ -597,6 +708,10 @@ Khi toàn bộ mục tiêu đã xong và đã kiểm chứng, kết thúc bằng
597
708
  state.yolo = !state.yolo;
598
709
  console.log((state.yolo ? c.err : c.ok)(" " + (state.yolo ? t.yoloOn : t.yoloOff)));
599
710
  break;
711
+ case "auto-yolo":
712
+ case "autoyolo":
713
+ await toggleAutoYolo();
714
+ break;
600
715
  case "karpathy":
601
716
  case "kcheck":
602
717
  case "kc":
@@ -835,6 +950,7 @@ function printHelp() {
835
950
  " " + t.cmdSearch,
836
951
  " " + t.cmdChat,
837
952
  " " + t.cmdYolo,
953
+ " " + t.cmdAutoYolo,
838
954
  " " + t.cmdKarpathy,
839
955
  " " + t.cmdUltra,
840
956
  " " + t.cmdLearn,
@@ -854,6 +970,7 @@ function printHelp() {
854
970
  c.dim(" " + t.tip1),
855
971
  c.dim(" " + t.tip2),
856
972
  c.dim(" " + t.tip3),
973
+ c.dim(" " + t.tip4),
857
974
  ].join("\n"),
858
975
  t.helpTitle,
859
976
  "#a78bfa",
package/src/tools.js CHANGED
@@ -16,6 +16,23 @@ function clip(s) {
16
16
  // Tools that mutate the filesystem or run code require user approval.
17
17
  export const DESTRUCTIVE = new Set(["write_file", "edit_file", "run_command"]);
18
18
 
19
+ // ── Tiến trình nền ─────────────────────────────────────────────────────────
20
+ // Lệnh chạy lâu / không bao giờ thoát (dev server, watcher) KHÔNG được chạy ở
21
+ // foreground: nó treo cả task rồi bị giết sau timeout. background:true → spawn
22
+ // rồi trả về NGAY, giữ tiến trình sống, gom output vào buffer để model đọc dần.
23
+ const bg = new Map(); // id -> { child, command, out, exited, code, startedAt }
24
+ let bgSeq = 0;
25
+ function killBgTree(child) {
26
+ try {
27
+ if (process.platform === "win32" && child.pid) spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"]);
28
+ else child.kill();
29
+ } catch {}
30
+ }
31
+ // Đừng để tiến trình nền sống mồ côi sau khi CLI thoát.
32
+ process.on("exit", () => {
33
+ for (const p of bg.values()) killBgTree(p.child);
34
+ });
35
+
19
36
  export const TOOLS = {
20
37
  async read_file({ path: p, offset, limit }) {
21
38
  const data = await fs.readFile(abs(p), "utf8");
@@ -145,17 +162,49 @@ export const TOOLS = {
145
162
  return out.length ? clip(out.join("\n")) : "No matches.";
146
163
  },
147
164
 
148
- run_command({ command, timeout = 60000 }) {
165
+ run_command({ command, timeout = 60000, background = false }) {
166
+ const isWin = process.platform === "win32";
167
+ const shell = isWin ? "powershell.exe" : "/bin/bash";
168
+ const args = isWin ? ["-NoProfile", "-NonInteractive", "-Command", command] : ["-c", command];
169
+ // stdin: "ignore" — tiến trình con KHÔNG được chạm vào console/stdin của
170
+ // CLI. Nếu để con kế thừa stdin, trên Windows nó có thể làm readline phát
171
+ // 'close' → CLI tự tắt. Đóng hẳn stdin con để tránh hoàn toàn.
172
+
173
+ // Nền: spawn rồi trả về NGAY, không chờ. Output gom vào buffer, model đọc
174
+ // bằng bg_output, dừng bằng kill_bg.
175
+ if (background) {
176
+ const id = ++bgSeq;
177
+ const child = spawn(shell, args, { cwd: cwd(), stdio: ["ignore", "pipe", "pipe"] });
178
+ const proc = { child, command, out: "", exited: false, code: null, startedAt: Date.now() };
179
+ const cap = (d) => {
180
+ proc.out += d;
181
+ if (proc.out.length > MAX_OUT * 2) proc.out = proc.out.slice(-MAX_OUT * 2); // giữ phần mới nhất
182
+ };
183
+ child.stdout.on("data", cap);
184
+ child.stderr.on("data", cap);
185
+ child.on("error", (e) => {
186
+ proc.exited = true;
187
+ proc.out += `\n[failed to start: ${e.message}]`;
188
+ });
189
+ child.on("close", (code) => {
190
+ proc.exited = true;
191
+ proc.code = code;
192
+ });
193
+ bg.set(id, proc);
194
+ return Promise.resolve(
195
+ `Started background process #${id} (pid ${child.pid ?? "?"}): ${command}\n` +
196
+ `It keeps running and does NOT block further steps. Read its output with bg_output {"id": ${id}}, stop it with kill_bg {"id": ${id}}.`,
197
+ );
198
+ }
199
+
149
200
  return new Promise((resolve) => {
150
- const isWin = process.platform === "win32";
151
- const shell = isWin ? "powershell.exe" : "/bin/bash";
152
- const args = isWin ? ["-NoProfile", "-NonInteractive", "-Command", command] : ["-c", command];
153
- // stdin: "ignore" — tiến trình con KHÔNG được chạm vào console/stdin của
154
- // CLI. Nếu để con kế thừa stdin, trên Windows nó có thể làm readline phát
155
- // 'close' → CLI tự tắt. Đóng hẳn stdin con để tránh hoàn toàn.
156
201
  const child = spawn(shell, args, { cwd: cwd(), stdio: ["ignore", "pipe", "pipe"] });
157
202
  let out = "";
158
- const killer = setTimeout(() => child.kill(), timeout);
203
+ let timedOut = false;
204
+ const killer = setTimeout(() => {
205
+ timedOut = true;
206
+ child.kill();
207
+ }, timeout);
159
208
  child.stdout.on("data", (d) => (out += d));
160
209
  child.stderr.on("data", (d) => (out += d));
161
210
  child.on("error", (e) => {
@@ -164,11 +213,36 @@ export const TOOLS = {
164
213
  });
165
214
  child.on("close", (code) => {
166
215
  clearTimeout(killer);
167
- const tail = `\n[exit code ${code}]`;
216
+ const tail = timedOut
217
+ ? `\n[timed out after ${Math.round(timeout / 1000)}s — killed. If this is a server or other long-running task, re-run with {"background": true} instead.]`
218
+ : `\n[exit code ${code}]`;
168
219
  resolve(clip((out.trim() || "(no output)") + tail));
169
220
  });
170
221
  });
171
222
  },
223
+
224
+ // Đọc trạng thái + output đã gom của tiến trình nền. Không có id → liệt kê tất cả.
225
+ bg_output({ id } = {}) {
226
+ if (id == null) {
227
+ if (!bg.size) return "No background processes.";
228
+ return [...bg.entries()]
229
+ .map(([i, p]) => `#${i} ${p.exited ? `exited(code ${p.code})` : `running(pid ${p.child.pid})`} — ${p.command}`)
230
+ .join("\n");
231
+ }
232
+ const p = bg.get(id);
233
+ if (!p) return `No background process #${id}.`;
234
+ const status = p.exited ? `exited (code ${p.code})` : `running (pid ${p.child.pid}, ${Math.round((Date.now() - p.startedAt) / 1000)}s)`;
235
+ return clip(`#${id} ${status} — ${p.command}\n${p.out.trim() || "(no output yet)"}`);
236
+ },
237
+
238
+ // Dừng một tiến trình nền (kèm cây con trên Windows).
239
+ kill_bg({ id }) {
240
+ const p = bg.get(id);
241
+ if (!p) return `No background process #${id}.`;
242
+ killBgTree(p.child);
243
+ bg.delete(id);
244
+ return `Killed background process #${id} (${p.command}).`;
245
+ },
172
246
  };
173
247
 
174
248
  // Khớp old_string theo KHỐI DÒNG, bỏ qua khác biệt CRLF và khoảng trắng CUỐI
@@ -269,7 +343,11 @@ export function describe(name, input) {
269
343
  case "grep":
270
344
  return `grep "${input.pattern}"${input.path ? " in " + input.path : ""}`;
271
345
  case "run_command":
272
- return `$ ${input.command}`;
346
+ return (input.background ? "$ (nền) " : "$ ") + input.command;
347
+ case "bg_output":
348
+ return input.id != null ? `xem tiến trình nền #${input.id}` : "liệt kê tiến trình nền";
349
+ case "kill_bg":
350
+ return `dừng tiến trình nền #${input.id}`;
273
351
  default:
274
352
  return name;
275
353
  }
package/src/tui.js CHANGED
@@ -53,6 +53,7 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
53
53
  },
54
54
  print() {},
55
55
  status() {},
56
+ setBusy() {},
56
57
  setPrompt() {},
57
58
  read() {
58
59
  if (queue.length) return Promise.resolve(queue.shift());
@@ -73,39 +74,94 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
73
74
 
74
75
  let liveOut = ""; // dòng output dở dang (chưa có '\n') hiện ngay trên thanh
75
76
  let statusText = null; // text spinner khi đang nghĩ
77
+ // `busy` = một lượt/tool ĐANG chạy. Hiện spinner dự phòng suốt lượt kể cả lúc
78
+ // statusText tạm trống (vd model ngừng phun token giữa các bước) → người dùng
79
+ // LUÔN thấy rõ "đang chạy", không bị tưởng treo.
80
+ let busy = false;
81
+ let busyLabel = "";
76
82
  let frame = 0;
77
83
  let frameTimer = null;
78
84
  let prevRows = 0;
79
85
  let drawn = false;
80
86
 
81
87
  let promptLabel = "";
82
- let parts = []; // input: {type:'text',value} | {type:'paste',value,lines}
88
+ // Thanh nhập = mảng "ô" + con trỏ. Mỗi ô là 1 ký tự {c} hoặc 1 khối dán
89
+ // {paste, lines} (hiện thành chip, sửa/xoá theo cả khối). `cur` ∈ [0, len] là
90
+ // vị trí con trỏ → hỗ trợ ←/→/Home/End/Delete, chèn & xoá GIỮA dòng.
91
+ let cells = [];
92
+ let cur = 0;
83
93
  let waiter = null;
84
94
  const queue = [];
85
95
 
86
- // ----- autocomplete: gợi ý lệnh hiện phía trên thanh nhập -----
96
+ // Lịch sử dòng đã gửi ↑/↓ gọi lại (khi không có menu gợi ý đang mở).
97
+ const submitHistory = [];
98
+ let histPos = null;
99
+
100
+ // ----- autocomplete: gợi ý lệnh / file hiện phía trên thanh nhập -----
101
+ // completer(text) → null | { items:[{name,desc}], start:int, fill:"submit"|"insert" }
102
+ // start = chỉ số trong text nơi token được thay (0 với /lệnh, vị trí '@' với @file)
103
+ // fill = "submit": Enter điền-rồi-gửi (lệnh); "insert": Enter chỉ chèn, gõ tiếp
87
104
  let menu = []; // [{name, desc}] khớp với những gì đang gõ
88
105
  let menuIdx = 0; // mục đang chọn (mũi tên ↑/↓), Tab để điền
106
+ let menuStart = 0;
107
+ let menuFill = "submit";
89
108
  function refreshMenu() {
90
- menu = completer ? completer(fullText()) || [] : [];
109
+ const res = completer ? completer(fullText()) : null;
110
+ if (res && res.items && res.items.length) {
111
+ menu = res.items;
112
+ menuStart = res.start || 0;
113
+ menuFill = res.fill || "submit";
114
+ } else {
115
+ menu = [];
116
+ menuStart = 0;
117
+ }
91
118
  menuIdx = 0;
92
119
  }
120
+ // Điền mục đang chọn: thay token [menuStart…] bằng tên + 1 dấu cách, giữ phần đầu.
121
+ function acceptMenu() {
122
+ const prefix = fullText().slice(0, menuStart);
123
+ setInput(prefix + menu[menuIdx].name + " ");
124
+ menu = [];
125
+ }
93
126
 
94
- // ----- input → chuỗi đầy đủ + bản hiển thị (paste = chip) -----
95
- const fullText = () => parts.map((p) => p.value).join("");
96
- const dispPlain = () => parts.map((p) => (p.type === "paste" ? `[pasted ${p.lines} lines]` : p.value)).join("");
97
- const dispColored = () =>
98
- parts.map((p) => (p.type === "paste" ? c.dim(`[pasted ${p.lines} lines]`) : p.value)).join("");
127
+ // ----- ô nhập → chuỗi đầy đủ + bản màu (paste = chip) -----
128
+ const cellStr = (x) => (x.paste !== undefined ? x.paste : x.c);
129
+ const cellPlain = (x) => (x.paste !== undefined ? `[pasted ${x.lines} lines]` : x.c);
130
+ const fullText = () => cells.map(cellStr).join("");
131
+ const coloredInput = () =>
132
+ cells.map((x) => (x.paste !== undefined ? c.dim(`[pasted ${x.lines} lines]`) : x.c)).join("");
99
133
 
134
+ // Dựng thanh nhập + tính cột con trỏ trên màn (`cursorScreenCol`). Vừa khung →
135
+ // tô màu đầy đủ. Tràn khung → cuộn ngang theo plain sao cho con trỏ luôn trong
136
+ // tầm nhìn, chèn "…" ở đầu/cuối bị cắt.
137
+ let cursorScreenCol = 0;
100
138
  function renderBar() {
101
- const budget = Math.max(4, cols() - visLen(promptLabel) - 1);
102
- const plain = dispPlain();
103
- if (plain.length <= budget) return promptLabel + dispColored();
104
- return promptLabel + "…" + plain.slice(plain.length - (budget - 1)); // giữ đuôi (chỗ đang gõ)
139
+ const promptW = visLen(promptLabel);
140
+ const budget = Math.max(4, cols() - promptW - 1);
141
+ const plains = cells.map(cellPlain);
142
+ let curCol = 0;
143
+ for (let k = 0; k < cur; k++) curCol += plains[k].length;
144
+ let total = 0;
145
+ for (const p of plains) total += p.length;
146
+ if (total <= budget) {
147
+ cursorScreenCol = promptW + curCol;
148
+ return promptLabel + coloredInput();
149
+ }
150
+ const plain = plains.join("");
151
+ let start = curCol > budget - 1 ? curCol - (budget - 1) : 0;
152
+ if (start > total - budget) start = total - budget;
153
+ if (start < 0) start = 0;
154
+ const end = Math.min(total, start + budget);
155
+ const arr = [...plain.slice(start, end)];
156
+ if (start > 0) arr[0] = "…";
157
+ if (end < total) arr[arr.length - 1] = "…";
158
+ cursorScreenCol = Math.min(promptW + budget, Math.max(promptW, promptW + (curCol - start)));
159
+ return promptLabel + arr.join("");
105
160
  }
106
161
  function topRow() {
107
162
  if (liveOut) return liveOut.slice(0, cols());
108
163
  if (statusText) return c.dim(FRAMES[frame % FRAMES.length] + " ") + statusText;
164
+ if (busy) return c.dim(FRAMES[frame % FRAMES.length] + " " + (busyLabel || "đang chạy") + " · Ctrl+C để dừng");
109
165
  return null;
110
166
  }
111
167
  function menuRows() {
@@ -133,9 +189,12 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
133
189
  if (!drawn) return "\r";
134
190
  return "\r" + (prevRows > 1 ? `${ESC}[${prevRows - 1}A` : "") + `${ESC}[J`;
135
191
  }
192
+ // Sau khi vẽ xong các hàng, con trỏ đang ở CUỐI thanh (hàng cuối). Đưa nó về
193
+ // đúng cột con trỏ logic: \r về cột 0 rồi dịch phải `cursorScreenCol`.
194
+ const placeCursor = () => "\r" + (cursorScreenCol > 0 ? `${ESC}[${cursorScreenCol}C` : "");
136
195
  function draw() {
137
- const rs = rows();
138
- w(`${ESC}[?25l` + eraseSeq() + rs.join("\n") + `${ESC}[?25h`);
196
+ const rs = rows(); // rows() → renderBar() cập nhật cursorScreenCol
197
+ w(`${ESC}[?25l` + eraseSeq() + rs.join("\n") + placeCursor() + `${ESC}[?25h`);
139
198
  prevRows = rs.length;
140
199
  drawn = true;
141
200
  }
@@ -145,7 +204,7 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
145
204
  let s = `${ESC}[?25l` + eraseSeq();
146
205
  s += block;
147
206
  if (!block.endsWith("\n")) s += "\n";
148
- s += rs.join("\n") + `${ESC}[?25h`;
207
+ s += rs.join("\n") + placeCursor() + `${ESC}[?25h`;
149
208
  w(s);
150
209
  prevRows = rs.length;
151
210
  drawn = true;
@@ -168,28 +227,76 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
168
227
  // ----- bàn phím: paste (bracketed) + heuristic chunk nhiều dòng -----
169
228
  function pushText(str) {
170
229
  if (!str) return;
171
- const last = parts[parts.length - 1];
172
- if (last && last.type === "text") last.value += str;
173
- else parts.push({ type: "text", value: str });
230
+ const chars = [...str].map((ch) => ({ c: ch }));
231
+ cells.splice(cur, 0, ...chars); // chèn TẠI con trỏ (không chỉ cuối dòng)
232
+ cur += chars.length;
233
+ histPos = null;
174
234
  }
175
235
  function pushPaste(content) {
176
236
  const lines = content.split("\n").length;
177
- if (lines >= 2) parts.push({ type: "paste", value: content, lines });
178
- else pushText(content); // paste 1 dòng = gõ thẳng
237
+ if (lines >= 2) {
238
+ cells.splice(cur, 0, { paste: content, lines });
239
+ cur += 1;
240
+ histPos = null;
241
+ } else pushText(content); // paste 1 dòng = gõ thẳng
179
242
  }
180
243
  function backspace() {
181
- const last = parts[parts.length - 1];
182
- if (!last) return;
183
- if (last.type === "paste") parts.pop();
184
- else {
185
- last.value = [...last.value].slice(0, -1).join("");
186
- if (!last.value) parts.pop();
244
+ if (cur > 0) {
245
+ cells.splice(cur - 1, 1);
246
+ cur -= 1;
247
+ histPos = null;
248
+ }
249
+ }
250
+ function deleteForward() {
251
+ if (cur < cells.length) {
252
+ cells.splice(cur, 1);
253
+ histPos = null;
254
+ }
255
+ }
256
+ // null = ô dán (coi như ranh giới từ); ngược lại trả ký tự của ô.
257
+ const charAt = (k) => (cells[k] && cells[k].paste === undefined ? cells[k].c : null);
258
+ function moveWordLeft() {
259
+ while (cur > 0 && charAt(cur - 1) === " ") cur -= 1;
260
+ if (cur > 0 && charAt(cur - 1) === null) return void (cur -= 1); // qua 1 chip
261
+ while (cur > 0 && charAt(cur - 1) !== null && charAt(cur - 1) !== " ") cur -= 1;
262
+ }
263
+ function moveWordRight() {
264
+ while (cur < cells.length && charAt(cur) === " ") cur += 1;
265
+ if (cur < cells.length && charAt(cur) === null) return void (cur += 1);
266
+ while (cur < cells.length && charAt(cur) !== null && charAt(cur) !== " ") cur += 1;
267
+ }
268
+ function setInput(str) {
269
+ cells = [...str].map((ch) => ({ c: ch }));
270
+ cur = cells.length;
271
+ }
272
+ // ↑/↓ gọi lại các dòng đã gửi (chỉ khi menu gợi ý KHÔNG mở).
273
+ function histNav(dir) {
274
+ if (menu.length || !submitHistory.length) return;
275
+ if (dir < 0) {
276
+ histPos = histPos === null ? submitHistory.length - 1 : Math.max(0, histPos - 1);
277
+ setInput(submitHistory[histPos]);
278
+ } else {
279
+ if (histPos === null) return;
280
+ histPos += 1;
281
+ if (histPos > submitHistory.length - 1) {
282
+ histPos = null;
283
+ cells = [];
284
+ cur = 0;
285
+ } else setInput(submitHistory[histPos]);
187
286
  }
287
+ refreshMenu();
288
+ draw();
188
289
  }
189
290
  function submit() {
190
291
  const full = fullText();
191
- const echo = promptLabel + dispColored();
192
- parts = [];
292
+ const echo = promptLabel + coloredInput();
293
+ if (full.trim() && submitHistory[submitHistory.length - 1] !== full) {
294
+ submitHistory.push(full);
295
+ if (submitHistory.length > 200) submitHistory.shift();
296
+ }
297
+ histPos = null;
298
+ cells = [];
299
+ cur = 0;
193
300
  menu = [];
194
301
  menuIdx = 0;
195
302
  commit(echo); // hiện lại dòng đã gõ (paste = chip) vào scrollback
@@ -233,38 +340,58 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
233
340
  if (rest.startsWith(`${ESC}[200~`)) { inPaste = true; pasteAcc = ""; i += 6; continue; }
234
341
  const ch = s[i];
235
342
  if (ch === ESC) {
236
- // Shift+Tab = CSI Z (\x1b[Z). Bắt TRỰC TIẾP ở đây cho chắc — decoder
237
- // keypress song song trước đây không nổ trên một số terminal Windows.
238
- if (rest.startsWith(`${ESC}[Z`)) { onShiftTab?.(); i += 3; continue; }
239
- // Mũi tên ↑/↓ điều hướng menu gợi ý lệnh (↑=A, ↓=B). ←/→ (C/D) nuốt.
240
- const arrow = rest.match(/^\x1b(?:\[|O)([ABCD])/);
241
- if (arrow) {
242
- if (menu.length && (arrow[1] === "A" || arrow[1] === "B")) {
243
- menuIdx = arrow[1] === "A" ? (menuIdx - 1 + menu.length) % menu.length : (menuIdx + 1) % menu.length;
244
- draw();
245
- }
246
- i += arrow[0].length;
343
+ // Một chuỗi CSI hoàn chỉnh: \x1b[ <chữ/~> hoặc SS3: \x1bO<chữ>.
344
+ const csi = rest.match(/^\x1b\[[0-9;]*[~A-Za-z]/) || rest.match(/^\x1bO[A-Za-z]/);
345
+ if (!csi) {
346
+ // Bị cắt ngang chunk giữ lại chờ phần còn lại. Còn ESC đơn/lạ → bỏ.
347
+ if (/^\x1b(\[[0-9;]*|O)?$/.test(rest)) { carry = rest; return; }
348
+ i += 1;
247
349
  continue;
248
350
  }
249
- const m = rest.match(/^\x1b\[[0-9;]*[A-Za-z~]/) || rest.match(/^\x1bO[A-Za-z]/);
250
- if (m) { i += m[0].length; continue; }
251
- if (rest.length < 3) { carry = rest; return; } // esc dở
252
- i += 1;
351
+ const seq = csi[0];
352
+ const fin = seq[seq.length - 1];
353
+ const params = seq.slice(2, -1).split(";"); // "\x1b[1;5C" → ["1","5"]
354
+ const mod = parseInt(params[1], 10) || 0; // 5=Ctrl, 3=Alt, 2=Shift
355
+ const word = mod === 5 || mod === 3; // Ctrl/Alt + ←/→ = nhảy theo từ
356
+ if (fin === "Z") onShiftTab?.(); // Shift+Tab = bật/tắt yolo
357
+ else if (fin === "A" || fin === "B") {
358
+ // ↑/↓: điều hướng menu gợi ý nếu đang mở, ngược lại gọi lại lịch sử.
359
+ if (menu.length) { menuIdx = fin === "A" ? (menuIdx - 1 + menu.length) % menu.length : (menuIdx + 1) % menu.length; draw(); }
360
+ else histNav(fin === "A" ? -1 : 1);
361
+ } else if (fin === "C") { if (word) moveWordRight(); else if (cur < cells.length) cur += 1; draw(); } // →
362
+ else if (fin === "D") { if (word) moveWordLeft(); else if (cur > 0) cur -= 1; draw(); } // ←
363
+ else if (fin === "H") { cur = 0; draw(); } // Home
364
+ else if (fin === "F") { cur = cells.length; draw(); } // End
365
+ else if (fin === "~") {
366
+ const n = parseInt(params[0], 10);
367
+ if (n === 3) { deleteForward(); refreshMenu(); draw(); } // Delete
368
+ else if (n === 1 || n === 7) { cur = 0; draw(); } // Home
369
+ else if (n === 4 || n === 8) { cur = cells.length; draw(); } // End
370
+ }
371
+ i += seq.length;
253
372
  continue;
254
373
  }
255
374
  if (ch === "\r" || ch === "\n") {
256
- if (menu.length) parts = [{ type: "text", value: menu[menuIdx].name }]; // Enter chọn mục đang sáng
257
- submit();
375
+ if (menu.length && menuFill === "insert") {
376
+ acceptMenu(); // @file: chỉ CHÈN vào dòng, KHÔNG gửi — cho gõ tiếp
377
+ draw();
378
+ } else {
379
+ if (menu.length) setInput(menu[menuIdx].name); // /lệnh: điền rồi gửi
380
+ submit();
381
+ }
258
382
  if (ch === "\r" && s[i + 1] === "\n") i++; // nuốt \n của \r\n
259
383
  i++;
260
384
  continue;
261
385
  }
262
- if (ch === "\x7f" || ch === "\b") { backspace(); refreshMenu(); draw(); i++; continue; }
386
+ if (ch === "\x7f" || ch === "\b") { backspace(); refreshMenu(); draw(); i++; continue; } // Backspace
263
387
  if (ch === "\x03") { onInterrupt?.(); i++; continue; } // Ctrl+C
264
- if (ch === "\x15") { parts = []; refreshMenu(); draw(); i++; continue; } // Ctrl+U xoá dòng
265
- if (ch === "\x04") { if (!fullText()) onEOF?.(); i++; continue; } // Ctrl+D
266
- if (ch === "\t") { // Tab điền lệnh đang chọn + dấu cách (để tham số), đóng menu
267
- if (menu.length) { parts = [{ type: "text", value: menu[menuIdx].name + " " }]; menu = []; draw(); }
388
+ if (ch === "\x01") { cur = 0; draw(); i++; continue; } // Ctrl+A đầu dòng
389
+ if (ch === "\x05") { cur = cells.length; draw(); i++; continue; } // Ctrl+E → cuối dòng
390
+ if (ch === "\x15") { cells = cells.slice(cur); cur = 0; histPos = null; refreshMenu(); draw(); i++; continue; } // Ctrl+U: xoá tới đầu dòng
391
+ if (ch === "\x0b") { cells = cells.slice(0, cur); histPos = null; refreshMenu(); draw(); i++; continue; } // Ctrl+K: xoá tới cuối dòng
392
+ if (ch === "\x04") { if (!fullText()) onEOF?.(); else { deleteForward(); refreshMenu(); draw(); } i++; continue; } // Ctrl+D: rỗng=EOF, else xoá ký tự
393
+ if (ch === "\t") { // Tab điền mục đang chọn (lệnh/file) + dấu cách, đóng menu
394
+ if (menu.length) { acceptMenu(); draw(); }
268
395
  i++;
269
396
  continue;
270
397
  }
@@ -310,7 +437,7 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
310
437
  return true;
311
438
  };
312
439
  frameTimer = setInterval(() => {
313
- if (statusText && !liveOut) {
440
+ if ((statusText || busy) && !liveOut) {
314
441
  frame++;
315
442
  draw();
316
443
  }
@@ -324,6 +451,11 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
324
451
  statusText = text || null;
325
452
  draw();
326
453
  },
454
+ setBusy(on, label) {
455
+ busy = !!on;
456
+ if (label != null) busyLabel = label;
457
+ draw();
458
+ },
327
459
  setPrompt(label) {
328
460
  promptLabel = label || "";
329
461
  draw();