@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 +1 -1
- package/src/agent.js +11 -10
- package/src/i18n.js +11 -1
- package/src/repl.js +105 -3
- package/src/tools.js +85 -6
- package/src/tui.js +25 -17
package/package.json
CHANGED
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
|
-
`-
|
|
110
|
+
`- Workspace (cwd): ${process.cwd()}`,
|
|
111
111
|
];
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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.
|
|
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
|
|
10
|
-
// nhận nếu
|
|
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
|
|
111
|
+
// ném OutOfScopeError để repl có 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
|
|
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
|
|
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
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
30
|
+
// Soft-wrap `text` thành các dòng có độ 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
|
|
52
|
+
while (remaining) {
|
|
45
53
|
if (visLen(remaining) <= width) {
|
|
46
|
-
lines.push(
|
|
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(
|
|
62
|
+
lines.push(remaining.slice(0, cutPos).trimEnd());
|
|
57
63
|
remaining = remaining.slice(cutPos).trimStart();
|
|
58
64
|
}
|
|
59
|
-
if (
|
|
60
|
-
const
|
|
61
|
-
const
|
|
62
|
-
const body =
|
|
63
|
-
|
|
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
|
|