@noobdemon/noob-cli 1.8.1 → 1.9.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.8.1",
3
+ "version": "1.9.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/src/agent.js CHANGED
@@ -107,20 +107,21 @@ function runtimeContext() {
107
107
  "# ENVIRONMENT",
108
108
  `- OS: ${process.platform} (${os.release()})`,
109
109
  `- Shell for run_command: ${isWin ? "Windows PowerShell (powershell.exe)" : "bash"}`,
110
- `- Current working directory: ${process.cwd()}`,
110
+ `- Workspace (cwd): ${process.cwd()}`,
111
111
  ];
112
- // Extra roots cấp qua /add-dir: model PHẢI biết để chủ động dùng (đọc/list/grep).
113
- // Không liệt đây model không 'thấy' thư mục đó tồn tại tools layer
114
- // đã accept path.
112
+ // Phạm vi truy cập filesystem. Model mặc định CHỈ được chạm cwd + các folder
113
+ // user đã /add-dir. Nếu cần folder NGOÀI phạm vi CỨ gọi tool với path tuyệt
114
+ // đối; repl sẽ tự hỏi user perm, nếu user đồng ý folder được thêm vào scope +
115
+ // lưu vào `.noob/dirs.json` của project. KHÔNG cần (và KHÔNG nên) yêu cầu user
116
+ // gõ `/add-dir` thủ công — cứ thử, hệ thống lo phần còn lại.
115
117
  try {
116
118
  const roots = listRoots();
117
119
  const extras = roots.slice(1); // [0] là cwd
118
- if (extras.length) {
119
- lines.push(
120
- `- Extra roots cấp quyền qua /add-dir (tool path nằm trong các thư mục này cũng hợp lệ — dùng path tuyệt đối khi gọi tool):`,
121
- );
122
- for (const r of extras) lines.push(` • ${r}`);
123
- }
120
+ lines.push(`- Filesystem scope: workspace + ${extras.length} extra root(s)${extras.length ? ":" : " (chỉ workspace)."}`);
121
+ for (const r of extras) lines.push(` • ${r}`);
122
+ lines.push(
123
+ `- Nếu cần folder NGOÀI scope: dùng path tuyệt đối trong tool call — repl sẽ hỏi user, nếu duyệt folder tự được thêm + persist theo workspace.`,
124
+ );
124
125
  } catch {}
125
126
  if (isWin) {
126
127
  lines.push(
package/src/i18n.js CHANGED
@@ -71,7 +71,7 @@ export const t = {
71
71
  cmdLearn: "/learn [ghi chú] chưng cất bài học của phiên vào noob.md",
72
72
  cmdCompact: "/compact tóm tắt phiên ngay để gọn ngữ cảnh (giữ trí nhớ dài hạn)",
73
73
  cmdMemory: "/memory xem bộ nhớ noob.md (/mem)",
74
- cmdAddDir: "/add-dir <path> thêm thư mục ngoài cwd vào phạm vi tool (không arg = liệt kê)",
74
+ cmdAddDir: "/add-dir <path> thêm thư mục ngoài cwd vào phạm vi tool (lưu theo workspace, không arg = liệt kê)",
75
75
  cmdClear: "/clear /new xoá ngữ cảnh (mở phiên mới, phiên cũ vẫn resume được)",
76
76
  cmdResume: "/resume [id] tiếp tục phiên cũ (không id = chọn từ danh sách)",
77
77
  cmdSessions: "/sessions liệt kê các phiên đã lưu",
@@ -89,6 +89,12 @@ export const t = {
89
89
  // misc
90
90
  yoloOn: "⚠ yolo BẬT — tự động duyệt mọi thao tác sửa file & chạy lệnh",
91
91
  yoloOff: "✓ yolo TẮT — sẽ hỏi trước khi sửa file & chạy lệnh",
92
+
93
+ // add-dir: auto-prompt khi model tag folder ngoài workspace
94
+ outOfScopeAdded: (root) => `✓ đã thêm ${root} vào phạm vi (lưu .noob/dirs.json).`,
95
+ outOfScopeRejected: (root) => `đã từ chối — ${root} không nằm trong phạm vi. Model có thể dùng /add-dir để thêm sau.`,
96
+ addDirRemoveNeedArg: "Thiếu path. Dùng: /add-dir remove <đường-dẫn>",
97
+ addDirNotInScope: (p) => `${p} không có trong phạm vi (chỉ cwd + các folder đã /add-dir).`,
92
98
  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.",
93
99
  autoYoloConfirm: "Chắc chắn lưu yolo làm mặc định? gõ 'y' để xác nhận, phím khác để huỷ › ",
94
100
  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.",
@@ -151,6 +157,10 @@ export const t = {
151
157
  workflowNoSkill: "không tìm thấy skills/dynamic-workflows/SKILL.md — skill chưa được cài.",
152
158
  workflowNeedArg: "cần mô tả task. Ví dụ: /workflow audit toàn bộ src/ tìm lỗ hổng SQL injection",
153
159
  workflowAgentAutoOn: "agent mode tự bật cho /workflow (cần spawn_agent)",
160
+ workflowAgentAskHint: "🎼 /workflow cần spawn sub-agent (spawn_agent) — agent mode hiện đang TẮT.",
161
+ workflowAgentAskPrompt: " bật agent mode và chạy workflow? [y] có, bật & chạy / [n] huỷ (gõ /agent rồi chạy lại nếu muốn) › ",
162
+ workflowAgentEnabled: "đã bật agent mode cho workflow này.",
163
+ workflowAgentDenied: "đã huỷ /workflow — agent mode vẫn TẮT. Gõ /agent rồi chạy lại lệnh nếu muốn.",
154
164
  // saved workflows (CRUD)
155
165
  workflowListEmpty: (dir) => `Chưa có workflow đã lưu. Tạo bằng /workflow save <name> <yêu cầu>. Thư mục: ${dir}`,
156
166
  workflowListHeader: (dir) => `Workflow đã lưu (${dir}):`,
package/src/repl.js CHANGED
@@ -7,7 +7,7 @@ import { runAgent, maybeSummarize } from "./agent.js";
7
7
  import { runSubAgent, spawnAgentToolsDoc, MAX_SUBAGENT_DEPTH } from "./subagent.js";
8
8
  import { TokenMeter } from "./tokens.js";
9
9
  import { stream, usage, ApiError, resetMemoryToken } from "./api.js";
10
- import { runTool, describe, DESTRUCTIVE, addRoot, listRoots } from "./tools.js";
10
+ import { runTool, describe, DESTRUCTIVE, addRoot, removeRoot, listRoots, OutOfScopeError, nearestExistingDir } from "./tools.js";
11
11
  import { MODELS, PROVIDERS, findModel, providerColor, DEFAULT_MODEL } from "./models.js";
12
12
  import { c, banner, modelBadge, renderMarkdown, box } from "./ui.js";
13
13
  import { config } from "./config.js";
@@ -413,13 +413,44 @@ Thực thi: đọc/tạo file cần thiết bằng tool, viết code production-
413
413
  await workflowExecute(arg);
414
414
  }
415
415
 
416
+ // Hỏi quyền bật agent mode để chạy workflow. CHỈ chấp nhận y/n (Enter = yes).
417
+ // Nếu nhận dòng lạ & dài (paste nhầm tin nhắn) → xếp hàng + hỏi lại (y hệt
418
+ // askPermission / askAddRoot) để user khỏi phải gõ lại /workflow.
419
+ async function askWorkflowAgentMode() {
420
+ tui.setBusy(false);
421
+ console.log(c.tool(" " + (t.workflowAgentAskHint || "🎼 /workflow cần spawn sub-agent — agent mode hiện đang TẮT.")));
422
+ try {
423
+ while (true) {
424
+ const raw = await ask(c.tool(" bật agent mode và chạy workflow? ") + c.dim("[y] có, bật & chạy / [n] huỷ (gõ /agent rồi chạy lại nếu muốn) › "));
425
+ if (raw == null) return "n"; // stdin đóng thật
426
+ const a = raw.trim().toLowerCase();
427
+ if (a === "" || a === "y" || a === "yes" || a === "có") return "y";
428
+ if (a === "n" || a === "no" || a === "không") return "n";
429
+ if (raw.trim().length > 3) {
430
+ pending.push(raw);
431
+ console.log(c.dim(" " + t.queued(pending.length, truncate(raw, 60))));
432
+ }
433
+ console.log(c.dim(" → gõ y hoặc n"));
434
+ }
435
+ } finally {
436
+ tui.setBusy(true, t.thinking);
437
+ }
438
+ }
439
+
416
440
  // Chạy thật workflow prompt — chia sẻ giữa ad-hoc và `run <name>`.
417
441
  async function workflowExecute(userRequest) {
418
442
  const skill = loadSkill("dynamic-workflows");
419
443
  if (!skill) return console.log(c.err(" " + (t.workflowNoSkill || "Không tìm thấy skill dynamic-workflows")));
420
444
  if (!state.agent) {
445
+ // Đừng tự bật — workflow cần spawn_agent, đây là quyền nặng (sub-agent chạy
446
+ // tool độc lập). Hỏi 1 lần, user chọn y thì bật & chạy, n thì huỷ sạch +
447
+ // gợi ý /agent. Tránh buộc user gõ lại /workflow sau khi /agent.
448
+ const choice = await askWorkflowAgentMode();
449
+ if (choice !== "y") {
450
+ return console.log(c.dim(" " + (t.workflowAgentDenied || "đã huỷ /workflow — agent mode vẫn TẮT. Gõ /agent rồi chạy lại lệnh nếu muốn.")));
451
+ }
421
452
  state.agent = true;
422
- console.log(c.tool(" " + (t.workflowAgentAutoOn || "agent mode tự bật cho /workflow")));
453
+ console.log(c.tool(" " + (t.workflowAgentEnabled || "đã bật agent mode cho workflow này.")));
423
454
  }
424
455
  const prompt = `Bạn đang thực thi SKILL "dynamic-workflows". Đọc kỹ playbook dưới đây và TUÂN THỦ khi orchestrate multi-agent workflow.
425
456
 
@@ -1197,6 +1228,16 @@ NGUYÊN TẮC:
1197
1228
  }
1198
1229
  }
1199
1230
 
1231
+ return await execToolCore(name, input, { retried: false });
1232
+ }
1233
+
1234
+ // Phần thân tool (tách riêng để retry khi user vừa approve thêm extra root).
1235
+ // Flow OutOfScopeError: tool ném → repl hỏi user "add folder X? [y/n/a]" → nếu
1236
+ // y/a: addRoot + persist (đã làm trong addRoot) + state.extraRoots sync + chạy
1237
+ // lại tool. Nếu n: trả lỗi cho model như cũ. Auto-prompt CHỈ chạy khi path là
1238
+ // tuyệt đối + có suggestedRoot hợp lệ (folder tồn tại) — tương đối escape cwd
1239
+ // thường là model tính sai, để model tự sửa.
1240
+ async function execToolCore(name, input, { retried }) {
1200
1241
  tui.status(c.dim(" " + t.running));
1201
1242
  try {
1202
1243
  const result = await runTool(name, input, { signal: abort?.signal });
@@ -1205,11 +1246,55 @@ NGUYÊN TẮC:
1205
1246
  return { allow: true, result };
1206
1247
  } catch (err) {
1207
1248
  tui.status(null);
1249
+ if (err instanceof OutOfScopeError && !retried && err.suggestedRoot) {
1250
+ const root = err.suggestedRoot;
1251
+ const a = await askAddRoot(root, err.path);
1252
+ if (a === "n") {
1253
+ console.log(c.err(" " + t.outOfScopeRejected(root)));
1254
+ return { allow: true, result: "ERROR: " + err.message };
1255
+ }
1256
+ try {
1257
+ addRoot(root);
1258
+ if (!state.extraRoots.includes(root)) state.extraRoots.push(root);
1259
+ if (a === "a") state.autoApprove.add("add-root");
1260
+ console.log(c.ok(" " + t.outOfScopeAdded(root)));
1261
+ } catch (e) {
1262
+ console.log(c.err(" ✗ " + (e?.message || String(e))));
1263
+ return { allow: true, result: "ERROR: " + err.message };
1264
+ }
1265
+ return await execToolCore(name, input, { retried: true });
1266
+ }
1208
1267
  console.log(c.err(" ✗ " + err.message));
1209
1268
  return { allow: true, result: "ERROR: " + err.message };
1210
1269
  }
1211
1270
  }
1212
1271
 
1272
+ // Hỏi user có muốn cấp quyền folder ngoài workspace cho tool call hiện tại
1273
+ // hay không. Trả về "y" | "n" | "a" (luôn). y = chỉ lần này; a = auto-approve
1274
+ // mọi add-root trong phiên. Cùng style retry với askPermission (lạc → queue).
1275
+ async function askAddRoot(root, targetPath) {
1276
+ tui.setBusy(false);
1277
+ console.log(c.tool(" ⏸ Cần cấp quyền folder: ") + c.accent(root));
1278
+ console.log(c.dim(" (model muốn truy cập: " + targetPath + ")"));
1279
+ try {
1280
+ while (true) {
1281
+ const raw = await ask(c.tool(" cho phép? ") + c.dim("[y] thêm vào scope lần này / [a] luôn thêm / [n] từ chối › "));
1282
+ if (raw == null) return "n";
1283
+ const a = raw.trim().toLowerCase();
1284
+ if (a === "" || a === "y" || a === "yes" || a === "có") return "y";
1285
+ if (a === "n" || a === "no" || a === "không") return "n";
1286
+ if (a === "a" || a === "always" || a === "luôn") return "a";
1287
+ if (raw.trim().length > 3) {
1288
+ pending.push(raw);
1289
+ console.log(c.dim(" " + t.queued(pending.length, truncate(raw, 60))));
1290
+ }
1291
+ console.log(c.dim(" → gõ y / n / a"));
1292
+ }
1293
+ } finally {
1294
+ tui.setBusy(true, t.thinking);
1295
+ }
1296
+ }
1297
+
1213
1298
  // Đọc câu trả lời cho phép một cách CHẮC CHẮN. Chỉ chấp nhận y/n/a (hoặc
1214
1299
  // Enter = đồng ý). Nếu nhận một dòng lạ & dài — gần như chắc là một tin nhắn
1215
1300
  // bị paste/gõ nhầm vào đúng lúc prompt hiện ra (đây là thủ phạm làm hỏng &
@@ -1407,6 +1492,22 @@ NGUYÊN TẮC:
1407
1492
  break;
1408
1493
  case "adddir":
1409
1494
  case "add-dir": {
1495
+ // /add-dir remove|rm <path> — gỡ khỏi scope (xóa cả trong file persist).
1496
+ if (/^(remove|rm)\b/i.test(arg)) {
1497
+ const target = arg.replace(/^(remove|rm)\s*/i, "").trim();
1498
+ if (!target) {
1499
+ console.log(c.err(" " + t.addDirRemoveNeedArg));
1500
+ break;
1501
+ }
1502
+ const full = path.resolve(process.cwd(), target);
1503
+ if (removeRoot(full)) {
1504
+ state.extraRoots = state.extraRoots.filter((r) => r !== full);
1505
+ console.log(c.ok(" ✓ ") + c.dim("đã gỡ khỏi phạm vi: ") + full);
1506
+ } else {
1507
+ console.log(c.err(" " + t.addDirNotInScope(full)));
1508
+ }
1509
+ break;
1510
+ }
1410
1511
  if (!arg) {
1411
1512
  // Không arg → liệt kê roots hiện tại (cwd + các thư mục đã /add-dir).
1412
1513
  const roots = listRoots();
@@ -1415,13 +1516,14 @@ NGUYÊN TẮC:
1415
1516
  const isCwd = r === process.cwd();
1416
1517
  console.log(" " + (isCwd ? c.accent("• ") : c.ok("+ ")) + r + (isCwd ? c.dim(" (cwd)") : ""));
1417
1518
  }
1418
- console.log(c.dim(" Dùng: /add-dir <đường-dẫn>"));
1519
+ console.log(c.dim(" Dùng: /add-dir <đường-dẫn> hoặc /add-dir remove <đường-dẫn>"));
1419
1520
  break;
1420
1521
  }
1421
1522
  try {
1422
1523
  const full = addRoot(path.resolve(process.cwd(), arg));
1423
1524
  if (!state.extraRoots.includes(full)) state.extraRoots.push(full);
1424
1525
  console.log(c.ok(" ✓ ") + c.dim("đã thêm vào phạm vi: ") + full);
1526
+ console.log(c.dim(" (đã lưu vào .noob/dirs.json — lần sau mở lại tự động áp dụng)"));
1425
1527
  } catch (e) {
1426
1528
  console.log(c.err(" ✗ ") + (e?.message || String(e)));
1427
1529
  }
package/src/tools.js CHANGED
@@ -6,9 +6,78 @@ import { spawn } from "node:child_process";
6
6
  const MAX_OUT = 30000; // hard cap on any tool result fed back to the model
7
7
  const cwd = () => process.cwd();
8
8
 
9
- // Các thư mục ngoài cwd được user cấp quyền qua /add-dir. Path tool sẽ chấp
10
- // nhận nếu nằm trong cwd HOẶC trong một extra root. Source of truth ở đây.
9
+ // Các thư mục ngoài cwd được user cấp quyền qua /add-dir (hoặc auto-prompt khi
10
+ // model tag folder lạ). Path tool sẽ chấp nhận nếu nằm trong cwd HOẶC một extra
11
+ // root. Source of truth ở đây; persisted per-workspace tại `<cwd>/.noob/dirs.json`
12
+ // để lần sau mở lại project không phải /add-dir lại từ đầu.
11
13
  const extraRoots = new Set();
14
+
15
+ // Lỗi đặc biệt: path nằm ngoài cả cwd lẫn extraRoots. repl.js bắt riêng để HỎI
16
+ // user perm thay vì trả thẳng cho model → UX mượt hơn (user không phải tự gõ
17
+ // /add-dir). `suggestedRoot` = thư mục gần nhất tồn tại để add vào scope.
18
+ export class OutOfScopeError extends Error {
19
+ constructor(p, suggestedRoot) {
20
+ super(`path nằm ngoài phạm vi (cwd + /add-dir): ${p}`);
21
+ this.name = "OutOfScopeError";
22
+ this.code = "OUT_OF_SCOPE";
23
+ this.path = p;
24
+ this.suggestedRoot = suggestedRoot;
25
+ }
26
+ }
27
+
28
+ // Tìm thư mục gần nhất TỒN TẠI để thêm vào extraRoots khi user approve.
29
+ // - Nếu p là folder có thật → trả p.
30
+ // - Nếu p là file có thật → trả parent folder.
31
+ // - Nếu p không tồn tại → walk lên tới khi gặp folder có thật (thường là tổ tiên
32
+ // đã /add-dir trước đó, hoặc 1 ancestor mà user định cấp quyền). Trả null
33
+ // nếu đi tới filesystem root mà vẫn không có gì tồn tại.
34
+ export function nearestExistingDir(p) {
35
+ if (!p) return null;
36
+ let cur = path.resolve(p);
37
+ while (true) {
38
+ try {
39
+ const st = fssync.statSync(cur);
40
+ if (st.isDirectory()) return cur;
41
+ return path.dirname(cur);
42
+ } catch {
43
+ const parent = path.dirname(cur);
44
+ if (parent === cur) return null;
45
+ cur = parent;
46
+ }
47
+ }
48
+ }
49
+
50
+ // Persist per-workspace. File `<cwd>/.noob/dirs.json` chứa mảng path tuyệt đối.
51
+ // Lưu NGAY khi addRoot được gọi (cả /add-dir lẫn auto-prompt path), nên user
52
+ // không phải /add-dir lại mỗi lần mở project. Nếu read-only hoặc permission
53
+ // deny → âm thầm bỏ qua (addRoot vẫn áp dụng cho phiên hiện tại).
54
+ const WORKSPACE_DIRS_FILE = () => path.join(cwd(), ".noob", "dirs.json");
55
+ function loadWorkspaceRoots() {
56
+ try {
57
+ const raw = fssync.readFileSync(WORKSPACE_DIRS_FILE(), "utf8");
58
+ const arr = JSON.parse(raw);
59
+ if (!Array.isArray(arr)) return;
60
+ for (const r of arr) {
61
+ if (typeof r !== "string") continue;
62
+ const full = path.resolve(r);
63
+ try {
64
+ if (fssync.statSync(full).isDirectory()) extraRoots.add(full);
65
+ } catch {}
66
+ }
67
+ } catch {}
68
+ }
69
+ function saveWorkspaceRoots() {
70
+ try {
71
+ const file = WORKSPACE_DIRS_FILE();
72
+ fssync.mkdirSync(path.dirname(file), { recursive: true });
73
+ fssync.writeFileSync(file, JSON.stringify([...extraRoots], null, 2), "utf8");
74
+ return true;
75
+ } catch {
76
+ return false;
77
+ }
78
+ }
79
+ loadWorkspaceRoots();
80
+
12
81
  export function addRoot(p) {
13
82
  if (!p) throw new Error("thiếu path");
14
83
  const full = path.resolve(p);
@@ -16,8 +85,17 @@ export function addRoot(p) {
16
85
  try { st = fssync.statSync(full); } catch { throw new Error("không tồn tại: " + p); }
17
86
  if (!st.isDirectory()) throw new Error("không phải thư mục: " + p);
18
87
  extraRoots.add(full);
88
+ saveWorkspaceRoots(); // persist per-workspace — lần sau mở project auto-load
19
89
  return full;
20
90
  }
91
+ export function removeRoot(p) {
92
+ const full = path.resolve(p);
93
+ if (extraRoots.delete(full)) {
94
+ saveWorkspaceRoots();
95
+ return true;
96
+ }
97
+ return false;
98
+ }
21
99
  export function listRoots() {
22
100
  return [cwd(), ...extraRoots];
23
101
  }
@@ -30,21 +108,22 @@ function within(root, full) {
30
108
  const abs = (p) => {
31
109
  if (!p) return cwd();
32
110
  // Path tuyệt đối: chấp nhận nếu nằm trong cwd hoặc một extra root, không thì
33
- // ném lỗi ràng (tools dưới sẽ propagate cho LLM).
111
+ // ném OutOfScopeError để repl thể catch riêng + hỏi user perm.
34
112
  if (path.isAbsolute(p)) {
35
113
  const full = path.resolve(p);
36
114
  if (within(cwd(), full)) return full;
37
115
  for (const r of extraRoots) if (within(r, full)) return full;
38
- throw new Error("path nằm ngoài phạm vi (cwd + /add-dir): " + p);
116
+ throw new OutOfScopeError(p, nearestExistingDir(p));
39
117
  }
40
- // Tương đối: ưu tiên cwd; nếu thoát cwd thì thử từng extra root.
118
+ // Tương đối: ưu tiên cwd; nếu thoát cwd thì thử từng extra root. Tương đối
119
+ // escape cwd thường là model tính sai — KHÔNG auto-prompt, chỉ trả lỗi rõ.
41
120
  const full = path.resolve(cwd(), p);
42
121
  if (within(cwd(), full)) return full;
43
122
  for (const r of extraRoots) {
44
123
  const fr = path.resolve(r, p);
45
124
  if (within(r, fr)) return fr;
46
125
  }
47
- throw new Error("path nằm ngoài phạm vi (cwd + /add-dir): " + p);
126
+ throw new OutOfScopeError(p, nearestExistingDir(p));
48
127
  };
49
128
  const rel = (p) => path.relative(cwd(), p) || ".";
50
129
  // Tên rút gọn để hiển thị: nếu path thuộc cwd → relative cwd; nếu thuộc một
package/src/tui.js CHANGED
@@ -27,42 +27,50 @@ function findVisPos(text, targetVis) {
27
27
  }
28
28
  return i;
29
29
  }
30
- // Soft-wrap `text` thành tối đa `maxLines` dòng, sao cho mỗi dòng độ rộng
31
- // VISUAL ≤ `width`. Ưu tiên cắt tại khoảng trắng gần cuối (word boundary); nếu
32
- // không có space hợp lý → hard-slice theo visual position. Nếu text gốc có
33
- // ANSI escape thì MỌI dòng output (kể cả dòng cuối "vừa khít") đều kết thúc
34
- // bằng `\x1b[0m` reset — chống "chảy máu" màu khi status bar có dim/accent.
35
- // Nếu vẫn còn dư → dòng cuối thêm "…".
30
+ // Soft-wrap `text` thành các dòng độ rộng VISUAL `width`. Ưu tiên cắt tại
31
+ // khoảng trắng gần cuối (word boundary); không có space hợp lý → hard-slice
32
+ // theo visual position. Nếu text gốc có ANSI escape thì MỌI dòng output (kể cả
33
+ // dòng cuối "vừa khít") đều kết thúc bằng `\x1b[0m` reset — chống "chảy máu"
34
+ // màu khi status bar có dim/accent.
35
+ //
36
+ // Tail-follow: sau khi wrap TOÀN BỘ, chỉ trả về `maxLines` dòng CUỐI. Hành vi
37
+ // này biến dòng topRow thành "page" cuối cùng của stream — khi AI phát sinh
38
+ // thêm text mới, page tự dịch xuống theo cursor (giống `tail -f`), user luôn
39
+ // thấy đúng phần đang được viết thay vì các dòng đầu tiên. Nếu wrap nhiều hơn
40
+ // `maxLines` dòng → dòng đầu page thêm "…" phía trước (và cắt 1 ký tự cuối để
41
+ // giữ nguyên độ rộng terminal, tránh re-wrap) để báo "có nội dung bị ẩn trên".
36
42
  function wrapText(text, width, maxLines) {
37
43
  if (!text) return [""];
38
44
  const hasAnsi = /\x1b/.test(text);
39
45
  const RESET = "\x1b[0m";
40
46
  const close = (line) => (hasAnsi ? line + RESET : line);
41
47
  if (visLen(text) <= width) return [close(text)];
48
+ // Wrap toàn bộ (không dừng ở maxLines) — cần đầy đủ để biết "page" hiện tại
49
+ // nằm ở đâu trong tổng stream.
42
50
  const lines = [];
43
51
  let remaining = text;
44
- while (remaining && lines.length < maxLines) {
52
+ while (remaining) {
45
53
  if (visLen(remaining) <= width) {
46
- lines.push(close(remaining));
54
+ lines.push(remaining);
47
55
  remaining = "";
48
56
  break;
49
57
  }
50
- // Cắt tại vị trí visual = width. Sau đó thử lùi về space gần nhất (trong
51
- // khoảng 30–100% width) để tránh cắt giữa từ.
52
58
  let cutPos = findVisPos(remaining, width);
53
59
  const slice = remaining.slice(0, cutPos);
54
60
  const lastSpace = slice.lastIndexOf(" ");
55
61
  if (lastSpace > width * 0.3) cutPos = lastSpace;
56
- lines.push(close(remaining.slice(0, cutPos).trimEnd()));
62
+ lines.push(remaining.slice(0, cutPos).trimEnd());
57
63
  remaining = remaining.slice(cutPos).trimStart();
58
64
  }
59
- if (remaining && lines.length) {
60
- const last = lines.length - 1;
61
- const lastLine = lines[last];
62
- const body = lastLine.endsWith(RESET) ? lastLine.slice(0, -RESET.length) : lastLine;
63
- lines[last] = (body.length ? body.slice(0, -1) : "") + "…" + (hasAnsi ? RESET : "");
65
+ if (lines.length > maxLines) {
66
+ const visible = lines.slice(-maxLines).map(close);
67
+ const first = visible[0];
68
+ 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 : "");
71
+ return visible;
64
72
  }
65
- return lines;
73
+ return lines.map(close);
66
74
  }
67
75
  const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
68
76