@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 +1 -1
- package/src/agent.js +7 -1
- package/src/config.js +8 -0
- package/src/i18n.js +7 -0
- package/src/repl.js +138 -21
- package/src/tools.js +88 -10
- package/src/tui.js +183 -51
package/package.json
CHANGED
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}
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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:
|
|
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
|
|
279
|
-
|
|
280
|
-
-
|
|
281
|
-
-
|
|
282
|
-
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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à 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à "đã làm"; nói "đã xong/đã sửa" trong văn xuôi mà 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ỉ mô 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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
// -----
|
|
95
|
-
const
|
|
96
|
-
const
|
|
97
|
-
const
|
|
98
|
-
|
|
127
|
+
// ----- ô nhập → chuỗi đầy đủ + bản tô 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
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
172
|
-
|
|
173
|
-
|
|
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)
|
|
178
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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 +
|
|
192
|
-
|
|
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
|
-
//
|
|
237
|
-
|
|
238
|
-
if (
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
|
257
|
-
|
|
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 === "\
|
|
265
|
-
if (ch === "\
|
|
266
|
-
if (ch === "\
|
|
267
|
-
|
|
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();
|