@noobdemon/noob-cli 1.2.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/bin/noob.js +3 -1
- package/package.json +1 -1
- package/src/agent.js +29 -2
- package/src/config.js +8 -0
- package/src/i18n.js +21 -0
- package/src/memory.js +21 -0
- package/src/repl.js +245 -4
- package/src/tools.js +88 -10
- package/src/tui.js +213 -46
package/bin/noob.js
CHANGED
|
@@ -7,12 +7,13 @@ import { t } from "../src/i18n.js";
|
|
|
7
7
|
import { checkLatest, runUpdate, CURRENT } from "../src/update.js";
|
|
8
8
|
|
|
9
9
|
const argv = process.argv.slice(2);
|
|
10
|
-
const opts = { yolo: false, model: undefined, prompt: undefined, continue: false, resume: false };
|
|
10
|
+
const opts = { yolo: false, ultra: false, model: undefined, prompt: undefined, continue: false, resume: false };
|
|
11
11
|
const positional = [];
|
|
12
12
|
|
|
13
13
|
for (let i = 0; i < argv.length; i++) {
|
|
14
14
|
const a = argv[i];
|
|
15
15
|
if (a === "--yolo" || a === "-y") opts.yolo = true;
|
|
16
|
+
else if (a === "--ultra" || a === "-u") opts.ultra = true;
|
|
16
17
|
else if (a === "--insecure-tls") process.env.NOOB_INSECURE_TLS = "1";
|
|
17
18
|
else if (a === "--model" || a === "-m") opts.model = argv[++i];
|
|
18
19
|
else if (a === "--continue" || a === "-c") opts.continue = true;
|
|
@@ -93,6 +94,7 @@ Cách dùng:
|
|
|
93
94
|
Tuỳ chọn:
|
|
94
95
|
-m, --model <id> chọn mô hình (vd: gateway-claude-opus-4-7)
|
|
95
96
|
-y, --yolo tự động duyệt sửa file & chạy lệnh (cẩn thận)
|
|
97
|
+
-u, --ultra tự hành: tự nghĩ & tự làm nhiệm vụ tới khi xong (kèm "yêu cầu")
|
|
96
98
|
-c, --continue tiếp tục phiên gần nhất (resume)
|
|
97
99
|
-r, --resume[=id] chọn phiên để tiếp tục (không id = hiện danh sách)
|
|
98
100
|
--insecure-tls tắt kiểm tra TLS (chỉ cho mạng có proxy chặn TLS)
|
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import os from "node:os";
|
|
2
2
|
import { stream } from "./api.js";
|
|
3
|
+
import { loadMemory } from "./memory.js";
|
|
3
4
|
import { t } from "./i18n.js";
|
|
4
5
|
|
|
5
6
|
export const SYSTEM = `You are noob, an agentic coding assistant in the spirit of Claude Code. You help with software engineering tasks by reading and editing files and running commands in the user's current working directory.
|
|
@@ -22,7 +23,9 @@ Available tools:
|
|
|
22
23
|
- list_dir {"path"?: str} — list a directory
|
|
23
24
|
- glob {"pattern": str} — find files by glob (supports ** and *)
|
|
24
25
|
- grep {"pattern": str, "path"?: str, "glob"?: str} — regex search file contents
|
|
25
|
-
- 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
|
|
26
29
|
|
|
27
30
|
# Rules
|
|
28
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.
|
|
@@ -30,10 +33,21 @@ Available tools:
|
|
|
30
33
|
- Make the smallest change that fully solves the task. Match the surrounding code style.
|
|
31
34
|
- Prefer edit_file over write_file for existing files.
|
|
32
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.
|
|
33
40
|
- Keep prose tight. Explain what you did and why, not how to use a tool.
|
|
34
41
|
- JSON in the tool block must be valid: escape newlines as \\n inside string values.
|
|
35
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.
|
|
36
43
|
|
|
44
|
+
# Self-memory (noob.md)
|
|
45
|
+
- The project root may hold \`noob.md\` — YOUR long-term memory. Its current contents are injected below under "PROJECT MEMORY". Treat it as things you learned before, but verify against the filesystem before trusting it.
|
|
46
|
+
- When you learn something durable and reusable — build/test/run commands, project conventions, architecture, user preferences, or progress on a long task — persist it: create \`noob.md\` with write_file if missing, otherwise edit_file to add/update. One fact per bullet, concise.
|
|
47
|
+
- Structure noob.md as two sections: \`## Rules\` (proven conventions you MUST follow — treat them as binding) and \`## Notes\` (observations not yet proven). Put new learnings under Notes.
|
|
48
|
+
- Self-improve loop: when a Note has proven true / recurred ~2–3 times, PROMOTE it into Rules and delete the duplicate Note. Keep noob.md bounded (~200 lines) — prune stale or contradicted entries, don't only append.
|
|
49
|
+
- Do NOT put transient chatter or secrets in noob.md.
|
|
50
|
+
|
|
37
51
|
# Coding principles (Karpathy) — apply to EVERY code change
|
|
38
52
|
1. THINK FIRST: state the key assumptions before you code. If a requirement is ambiguous or a step is hard to reverse, ask ONE sharp question instead of guessing.
|
|
39
53
|
2. KEEP IT SIMPLE: write the simplest thing that works. No speculative abstractions, no extra flags/config/layers "for later". Prefer deleting code to adding it.
|
|
@@ -130,10 +144,23 @@ function filesLedger(history) {
|
|
|
130
144
|
);
|
|
131
145
|
}
|
|
132
146
|
|
|
147
|
+
// Chèn bộ nhớ noob.md (nếu có) vào prompt — đây là phần "tự học" mà noob đọc
|
|
148
|
+
// lại mỗi lượt. Không có file → nhắc model tự tạo khi rút ra điều đáng nhớ.
|
|
149
|
+
function memoryBlock() {
|
|
150
|
+
const mem = loadMemory();
|
|
151
|
+
if (!mem)
|
|
152
|
+
return "# PROJECT MEMORY (noob.md)\n(Chưa có noob.md trong thư mục này. Khi rút ra điều đáng nhớ lâu dài — lệnh build/test/run, quy ước, kiến trúc, sở thích người dùng, tiến độ — hãy TẠO noob.md bằng write_file và ghi vào đó.)";
|
|
153
|
+
return (
|
|
154
|
+
"# PROJECT MEMORY (noob.md — điều bạn đã tự học trước đó; xác minh với filesystem trước khi tin tuyệt đối)\n" +
|
|
155
|
+
mem +
|
|
156
|
+
"\n(Học thêm điều đáng nhớ → cập nhật noob.md bằng edit_file/write_file.)"
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
133
160
|
// The proxy is stateless, so we serialize the whole transcript into one prompt.
|
|
134
161
|
function buildPrompt(history) {
|
|
135
162
|
const msgs = compact(history, MAX_PROMPT_CHARS);
|
|
136
|
-
const parts = [SYSTEM, "", runtimeContext(), "", filesLedger(history), "", "=".repeat(60), "# CONVERSATION", ""];
|
|
163
|
+
const parts = [SYSTEM, "", runtimeContext(), "", memoryBlock(), "", filesLedger(history), "", "=".repeat(60), "# CONVERSATION", ""];
|
|
137
164
|
for (const m of msgs) {
|
|
138
165
|
if (m.role === "user") parts.push(`## USER\n${m.content}`);
|
|
139
166
|
else if (m.role === "assistant") parts.push(`## ASSISTANT\n${m.content}`);
|
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,7 +57,11 @@ 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)",
|
|
62
|
+
cmdUltra: "/ultra <mục tiêu> tự hành: noob tự nghĩ & tự làm nhiệm vụ tới khi xong (/u)",
|
|
63
|
+
cmdLearn: "/learn [ghi chú] chưng cất bài học của phiên vào noob.md",
|
|
64
|
+
cmdMemory: "/memory xem bộ nhớ noob.md (/mem)",
|
|
61
65
|
cmdClear: "/clear /new xoá ngữ cảnh (mở phiên mới, phiên cũ vẫn resume được)",
|
|
62
66
|
cmdResume: "/resume [id] tiếp tục phiên cũ (không id = chọn từ danh sách)",
|
|
63
67
|
cmdSessions: "/sessions liệt kê các phiên đã lưu",
|
|
@@ -70,10 +74,16 @@ export const t = {
|
|
|
70
74
|
tip1: "• Mô tả việc cần làm; noob sẽ đọc/sửa file & chạy lệnh giúp bạn.",
|
|
71
75
|
tip2: "• Đang chạy vẫn gõ tiếp được — tin sẽ xếp hàng & tự gửi khi model xong.",
|
|
72
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ũ.",
|
|
73
78
|
|
|
74
79
|
// misc
|
|
75
80
|
yoloOn: "⚠ yolo BẬT — tự động duyệt mọi thao tác sửa file & chạy lệnh",
|
|
76
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ì.",
|
|
77
87
|
mergeOn: "Merge AI: BẬT",
|
|
78
88
|
mergeOff: "Merge AI: TẮT",
|
|
79
89
|
searchOn: "Tìm web: BẬT",
|
|
@@ -90,6 +100,17 @@ export const t = {
|
|
|
90
100
|
maxSteps: "_(đã dừng: chạm giới hạn số bước tool)_",
|
|
91
101
|
toolDenied: "Người dùng từ chối thao tác này. Hãy đổi cách làm hoặc hỏi lại.",
|
|
92
102
|
|
|
103
|
+
// ultra (tự hành / self-quest) + bộ nhớ noob.md
|
|
104
|
+
ultraOn: "Ultra: BẬT — noob tự lập kế hoạch & tự làm tới khi xong (Ctrl+C để dừng).",
|
|
105
|
+
ultraDone: "Ultra: đã hoàn thành mục tiêu.",
|
|
106
|
+
ultraStopped: "Ultra: đã dừng.",
|
|
107
|
+
ultraMax: "Ultra: chạm giới hạn số vòng — dừng để bạn kiểm tra & ra lệnh tiếp.",
|
|
108
|
+
ultraNeedGoal: "Cần mục tiêu. Dùng: /ultra <mô tả mục tiêu>",
|
|
109
|
+
ultraQuest: (n) => `tự nghĩ nhiệm vụ kế tiếp (vòng ${n})…`,
|
|
110
|
+
learning: "đang chưng cất bài học vào noob.md…",
|
|
111
|
+
memoryEmpty: (p) => `Chưa có noob.md. noob sẽ tự tạo ở: ${p}`,
|
|
112
|
+
memoryStat: (n) => ` · ${n} dòng / ~200`,
|
|
113
|
+
|
|
93
114
|
// sessions (lưu lịch sử + resume)
|
|
94
115
|
sessionResumed: (id) => `Đã khôi phục phiên ${id}`,
|
|
95
116
|
sessionNonePrev: "Chưa có phiên nào trước đó — bắt đầu phiên mới.",
|
package/src/memory.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Bộ nhớ lâu dài của noob: file `noob.md` ở thư mục gốc dự án (giống CLAUDE.md /
|
|
2
|
+
// AGENTS.md). noob TỰ tạo & TỰ cập nhật nó qua write_file/edit_file để học và
|
|
3
|
+
// nhớ giữa các phiên. Runtime chỉ ĐỌC để chèn vào prompt — không tự ghi đè.
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
const FILE = "noob.md";
|
|
8
|
+
|
|
9
|
+
export function memoryPath() {
|
|
10
|
+
return path.resolve(process.cwd(), FILE);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Nội dung noob.md hiện tại, hoặc null nếu chưa có / rỗng. */
|
|
14
|
+
export function loadMemory() {
|
|
15
|
+
try {
|
|
16
|
+
const txt = fs.readFileSync(memoryPath(), "utf8").trim();
|
|
17
|
+
return txt || null;
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
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";
|
|
@@ -7,17 +9,123 @@ import { runTool, describe, DESTRUCTIVE } from "./tools.js";
|
|
|
7
9
|
import { MODELS, PROVIDERS, findModel, providerColor, DEFAULT_MODEL } from "./models.js";
|
|
8
10
|
import { c, banner, modelBadge, renderMarkdown, box } from "./ui.js";
|
|
9
11
|
import { config } from "./config.js";
|
|
12
|
+
import { loadMemory, memoryPath } from "./memory.js";
|
|
10
13
|
import { t } from "./i18n.js";
|
|
11
14
|
import { checkLatest, runUpdate, CURRENT } from "./update.js";
|
|
12
15
|
import * as sessions from "./sessions.js";
|
|
13
16
|
|
|
17
|
+
// Lệnh dùng cho autocomplete. Gõ "/l" → lọc các lệnh có "l" (login, logout,
|
|
18
|
+
// clear, models, yolo…); ↑/↓ chọn, Tab điền, Enter chạy mục đang sáng.
|
|
19
|
+
const SLASH = [
|
|
20
|
+
{ name: "/help", desc: "danh sách lệnh" },
|
|
21
|
+
{ name: "/model", desc: "đổi mô hình" },
|
|
22
|
+
{ name: "/models", desc: "liệt kê mô hình" },
|
|
23
|
+
{ name: "/merge", desc: "bật/tắt Merge AI" },
|
|
24
|
+
{ name: "/search", desc: "bật/tắt tìm web" },
|
|
25
|
+
{ name: "/chat", desc: "chế độ chat thường" },
|
|
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)" },
|
|
28
|
+
{ name: "/karpathy", desc: "rà soát code (Karpathy)" },
|
|
29
|
+
{ name: "/ultra", desc: "tự hành: tự nghĩ & làm nhiệm vụ" },
|
|
30
|
+
{ name: "/learn", desc: "chưng cất bài học vào noob.md" },
|
|
31
|
+
{ name: "/memory", desc: "xem bộ nhớ noob.md" },
|
|
32
|
+
{ name: "/login", desc: "đăng nhập bằng API key" },
|
|
33
|
+
{ name: "/logout", desc: "đăng xuất" },
|
|
34
|
+
{ name: "/usage", desc: "xem hạn mức key" },
|
|
35
|
+
{ name: "/update", desc: "cập nhật noob" },
|
|
36
|
+
{ name: "/clear", desc: "xoá ngữ cảnh / phiên mới" },
|
|
37
|
+
{ name: "/resume", desc: "tiếp tục phiên cũ" },
|
|
38
|
+
{ name: "/continue", desc: "tiếp tục phiên gần nhất" },
|
|
39
|
+
{ name: "/sessions", desc: "liệt kê phiên đã lưu" },
|
|
40
|
+
{ name: "/cwd", desc: "thư mục hiện tại" },
|
|
41
|
+
{ name: "/status", desc: "trạng thái" },
|
|
42
|
+
{ name: "/version", desc: "phiên bản" },
|
|
43
|
+
{ name: "/exit", desc: "thoát" },
|
|
44
|
+
];
|
|
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];
|
|
119
|
+
}
|
|
120
|
+
|
|
14
121
|
export async function startRepl(opts = {}) {
|
|
15
122
|
const state = {
|
|
16
123
|
model: findModel(opts.model) || findModel(config.model) || findModel(DEFAULT_MODEL),
|
|
17
124
|
mode: "chat", // chat | merge | search
|
|
18
125
|
history: [],
|
|
19
126
|
autoApprove: new Set(),
|
|
20
|
-
yolo: !!opts.yolo,
|
|
127
|
+
yolo: !!opts.yolo || config.yoloDefault, // cờ --yolo HOẶC mặc định đã lưu (/auto-yolo)
|
|
128
|
+
ultra: false, // chế độ tự hành (self-quest) đang chạy?
|
|
21
129
|
};
|
|
22
130
|
|
|
23
131
|
// Prompt = dòng trạng thái sống. Luôn phản ánh yolo + version theo thời gian
|
|
@@ -62,6 +170,7 @@ export async function startRepl(opts = {}) {
|
|
|
62
170
|
tui.print(state.yolo ? c.err(" " + t.yoloOn) : c.ok(" " + t.yoloOff));
|
|
63
171
|
tui.setPrompt(promptStr(false));
|
|
64
172
|
},
|
|
173
|
+
completer: completeInput,
|
|
65
174
|
});
|
|
66
175
|
|
|
67
176
|
// Dòng "tươi" cho prompt chính VÀ permission. KHÔNG đụng `pending`.
|
|
@@ -96,6 +205,10 @@ export async function startRepl(opts = {}) {
|
|
|
96
205
|
if (abort) {
|
|
97
206
|
abort.abort();
|
|
98
207
|
abort = null;
|
|
208
|
+
if (state.ultra) {
|
|
209
|
+
state.ultra = false; // Ctrl+C cũng dừng vòng tự hành, không chỉ lượt hiện tại
|
|
210
|
+
console.log(c.tool(" " + t.ultraStopped));
|
|
211
|
+
}
|
|
99
212
|
if (pending.length) {
|
|
100
213
|
const n = pending.length;
|
|
101
214
|
pending.length = 0; // ngắt lượt → xoá luôn tin đang xếp hàng
|
|
@@ -226,6 +339,104 @@ Cuối cùng: tóm tắt + danh sách sửa theo thứ tự ưu tiên. Thẳng t
|
|
|
226
339
|
persist();
|
|
227
340
|
}
|
|
228
341
|
|
|
342
|
+
// ── ULTRA: chế độ tự hành (self-quest) ────────────────────────────────────
|
|
343
|
+
// noob tự lập kế hoạch, TỰ chọn nhiệm vụ con kế tiếp, tự thực hiện & tự kiểm
|
|
344
|
+
// chứng — lặp tới khi model phát token ULTRA_DONE, chạm giới hạn vòng, hoặc
|
|
345
|
+
// người dùng Ctrl+C. Mỗi vòng là một lượt agent đầy đủ (dùng lại handle()).
|
|
346
|
+
const ULTRA_DONE = "<<ULTRA_DONE>>";
|
|
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);
|
|
352
|
+
const ultraStart = (goal) => `# CHẾ ĐỘ ULTRA (tự hành)
|
|
353
|
+
Mục tiêu tổng: ${goal}
|
|
354
|
+
|
|
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).`;
|
|
372
|
+
|
|
373
|
+
async function runUltra(goal) {
|
|
374
|
+
if (!config.apiKey) return console.log(c.tool(" " + t.notLoggedIn));
|
|
375
|
+
if (!goal) return console.log(c.err(" " + t.ultraNeedGoal));
|
|
376
|
+
state.mode = "chat"; // tự hành chỉ chạy ở chế độ agent
|
|
377
|
+
state.ultra = true;
|
|
378
|
+
console.log(c.accent(" 🚀 " + t.ultraOn));
|
|
379
|
+
let answer = await handle(ultraStart(goal));
|
|
380
|
+
persist();
|
|
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.
|
|
384
|
+
while (state.ultra && i < MAX_QUESTS) {
|
|
385
|
+
if (!answer) break; // lượt bị ngắt/ lỗi → dừng tự hành, đừng quay vô ích
|
|
386
|
+
i++;
|
|
387
|
+
console.log(c.accent(" ↻ " + t.ultraQuest(i)));
|
|
388
|
+
answer = await handle(ultraContinue(goal));
|
|
389
|
+
persist();
|
|
390
|
+
if (answer && ultraIsDone(answer)) {
|
|
391
|
+
console.log(c.ok(" ✓ " + t.ultraDone));
|
|
392
|
+
break;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
if (state.ultra && i >= MAX_QUESTS) console.log(c.tool(" " + t.ultraMax));
|
|
396
|
+
state.ultra = false;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// /learn [ghi chú] — bắt noob chưng cất điều đáng nhớ của phiên vào noob.md.
|
|
400
|
+
async function runLearn(arg) {
|
|
401
|
+
if (!config.apiKey) return console.log(c.tool(" " + t.notLoggedIn));
|
|
402
|
+
const note = arg ? `Người dùng nhấn mạnh cần nhớ: "${arg}"\n` : "";
|
|
403
|
+
const prompt = `${note}Hãy CHƯNG CẤT những điều đáng nhớ lâu dài từ phiên này và cập nhật noob.md ở thư mục gốc dự án.
|
|
404
|
+
- Đọc noob.md hiện có trước (chưa có thì tạo bằng write_file). noob.md có 2 mục: "## Rules" (quy ước đã chốt — bắt buộc tuân theo) và "## Notes" (quan sát chưa chốt).
|
|
405
|
+
- Ghi cái mới vào Notes: lệnh build/test/run, quy ước code, kiến trúc, sở thích người dùng, quyết định quan trọng, việc còn dang dở.
|
|
406
|
+
- Note nào đã đúng/lặp lại ~2–3 lần → CHUYỂN lên Rules và xoá Note trùng (vòng tự cải thiện).
|
|
407
|
+
- Mỗi ý 1 gạch đầu dòng, ngắn gọn, đúng sự thật. Giữ noob.md gọn (~200 dòng): cắt mục cũ/sai, đừng chỉ thêm.
|
|
408
|
+
- Chỉ ghi qua tool (write_file/edit_file). Xong thì tóm tắt ngắn bạn đã thêm/sửa/chuyển gì.`;
|
|
409
|
+
console.log(c.tool(" 🧠 " + t.learning));
|
|
410
|
+
await handle(prompt);
|
|
411
|
+
persist();
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function showMemory() {
|
|
415
|
+
const mem = loadMemory();
|
|
416
|
+
if (!mem) return console.log(c.dim(" " + t.memoryEmpty(memoryPath())));
|
|
417
|
+
console.log(box(mem.length > 1800 ? mem.slice(0, 1800) + "\n…" : mem, "noob.md", "#10b981"));
|
|
418
|
+
console.log(c.dim(" " + memoryPath() + t.memoryStat(mem.split("\n").length)));
|
|
419
|
+
}
|
|
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
|
+
|
|
229
440
|
tui.start();
|
|
230
441
|
banner();
|
|
231
442
|
printStatus(state);
|
|
@@ -267,8 +478,11 @@ Cuối cùng: tóm tắt + danh sách sửa theo thứ tự ưu tiên. Thẳng t
|
|
|
267
478
|
|
|
268
479
|
if (opts.prompt) {
|
|
269
480
|
console.log(c.user(t.promptYou) + c.dim("› ") + opts.prompt);
|
|
270
|
-
await
|
|
271
|
-
|
|
481
|
+
if (opts.ultra) await runUltra(opts.prompt);
|
|
482
|
+
else {
|
|
483
|
+
await handle(opts.prompt);
|
|
484
|
+
persist();
|
|
485
|
+
}
|
|
272
486
|
}
|
|
273
487
|
|
|
274
488
|
// Main loop — runs until /exit, double Ctrl+C, or EOF. Never exits after a task.
|
|
@@ -309,6 +523,7 @@ Cuối cùng: tóm tắt + danh sách sửa theo thứ tự ưu tiên. Thẳng t
|
|
|
309
523
|
return;
|
|
310
524
|
}
|
|
311
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)
|
|
312
527
|
// spinner: status hiển thị qua tui (thanh dưới), tui tự animate khung xoay
|
|
313
528
|
const t0 = Date.now();
|
|
314
529
|
let timer = null;
|
|
@@ -352,7 +567,11 @@ Cuối cùng: tóm tắt + danh sách sửa theo thứ tự ưu tiên. Thẳng t
|
|
|
352
567
|
return;
|
|
353
568
|
}
|
|
354
569
|
|
|
355
|
-
|
|
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 });
|
|
356
575
|
if (process.stdin.isTTY && !state.steerHintShown) {
|
|
357
576
|
console.log(c.dim(" " + t.steerHint));
|
|
358
577
|
state.steerHintShown = true;
|
|
@@ -397,12 +616,14 @@ Cuối cùng: tóm tắt + danh sách sửa theo thứ tự ưu tiên. Thẳng t
|
|
|
397
616
|
// Lượt cuối không stream ra gì (vd model suy luận trả 1 cục) → in fallback.
|
|
398
617
|
if ((!printer || !printer.started) && answer?.trim())
|
|
399
618
|
printAnswer(answer, state.model.name, providerColor(state.model.provider));
|
|
619
|
+
return answer; // vòng ULTRA cần text này để dò token hoàn thành
|
|
400
620
|
} catch (err) {
|
|
401
621
|
stopSpin();
|
|
402
622
|
if (err.name === "AbortError") return;
|
|
403
623
|
printError(err);
|
|
404
624
|
} finally {
|
|
405
625
|
abort = null;
|
|
626
|
+
tui.setBusy(false);
|
|
406
627
|
}
|
|
407
628
|
}
|
|
408
629
|
|
|
@@ -487,11 +708,26 @@ Cuối cùng: tóm tắt + danh sách sửa theo thứ tự ưu tiên. Thẳng t
|
|
|
487
708
|
state.yolo = !state.yolo;
|
|
488
709
|
console.log((state.yolo ? c.err : c.ok)(" " + (state.yolo ? t.yoloOn : t.yoloOff)));
|
|
489
710
|
break;
|
|
711
|
+
case "auto-yolo":
|
|
712
|
+
case "autoyolo":
|
|
713
|
+
await toggleAutoYolo();
|
|
714
|
+
break;
|
|
490
715
|
case "karpathy":
|
|
491
716
|
case "kcheck":
|
|
492
717
|
case "kc":
|
|
493
718
|
await runKarpathy(arg);
|
|
494
719
|
break;
|
|
720
|
+
case "ultra":
|
|
721
|
+
case "u":
|
|
722
|
+
await runUltra(arg);
|
|
723
|
+
break;
|
|
724
|
+
case "learn":
|
|
725
|
+
await runLearn(arg);
|
|
726
|
+
break;
|
|
727
|
+
case "memory":
|
|
728
|
+
case "mem":
|
|
729
|
+
showMemory();
|
|
730
|
+
break;
|
|
495
731
|
case "login":
|
|
496
732
|
doLogin(arg);
|
|
497
733
|
break;
|
|
@@ -714,7 +950,11 @@ function printHelp() {
|
|
|
714
950
|
" " + t.cmdSearch,
|
|
715
951
|
" " + t.cmdChat,
|
|
716
952
|
" " + t.cmdYolo,
|
|
953
|
+
" " + t.cmdAutoYolo,
|
|
717
954
|
" " + t.cmdKarpathy,
|
|
955
|
+
" " + t.cmdUltra,
|
|
956
|
+
" " + t.cmdLearn,
|
|
957
|
+
" " + t.cmdMemory,
|
|
718
958
|
" " + t.cmdLogin,
|
|
719
959
|
" " + t.cmdLogout,
|
|
720
960
|
" " + t.cmdUsage,
|
|
@@ -730,6 +970,7 @@ function printHelp() {
|
|
|
730
970
|
c.dim(" " + t.tip1),
|
|
731
971
|
c.dim(" " + t.tip2),
|
|
732
972
|
c.dim(" " + t.tip3),
|
|
973
|
+
c.dim(" " + t.tip4),
|
|
733
974
|
].join("\n"),
|
|
734
975
|
t.helpTitle,
|
|
735
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
|
@@ -14,7 +14,7 @@ const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
|
|
|
14
14
|
const visLen = (s) => s.replace(ANSI_RE, "").length;
|
|
15
15
|
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
16
16
|
|
|
17
|
-
export function createTui({ onLine, onInterrupt, onEOF, onShiftTab } = {}) {
|
|
17
|
+
export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer } = {}) {
|
|
18
18
|
const tty = !!(process.stdout.isTTY && process.stdin.isTTY) && process.env.NOOB_TUI !== "0";
|
|
19
19
|
const cols = () => process.stdout.columns || 80;
|
|
20
20
|
|
|
@@ -53,6 +53,7 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab } = {}) {
|
|
|
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,37 +74,114 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab } = {}) {
|
|
|
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
|
-
//
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
const dispColored = () =>
|
|
90
|
-
parts.map((p) => (p.type === "paste" ? c.dim(`[pasted ${p.lines} lines]`) : p.value)).join("");
|
|
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;
|
|
91
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
|
|
104
|
+
let menu = []; // [{name, desc}] khớp với những gì đang gõ
|
|
105
|
+
let menuIdx = 0; // mục đang chọn (mũi tên ↑/↓), Tab để điền
|
|
106
|
+
let menuStart = 0;
|
|
107
|
+
let menuFill = "submit";
|
|
108
|
+
function refreshMenu() {
|
|
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
|
+
}
|
|
118
|
+
menuIdx = 0;
|
|
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
|
+
}
|
|
126
|
+
|
|
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("");
|
|
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;
|
|
92
138
|
function renderBar() {
|
|
93
|
-
const
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
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("");
|
|
97
160
|
}
|
|
98
161
|
function topRow() {
|
|
99
162
|
if (liveOut) return liveOut.slice(0, cols());
|
|
100
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");
|
|
101
165
|
return null;
|
|
102
166
|
}
|
|
167
|
+
function menuRows() {
|
|
168
|
+
if (!menu.length) return [];
|
|
169
|
+
const MAXV = 8; // cửa sổ cuộn quanh mục đang chọn
|
|
170
|
+
let start = 0;
|
|
171
|
+
if (menu.length > MAXV) start = Math.min(Math.max(0, menuIdx - 3), menu.length - MAXV);
|
|
172
|
+
return menu.slice(start, start + MAXV).map((m, k) => {
|
|
173
|
+
const sel = start + k === menuIdx;
|
|
174
|
+
const budget = Math.max(0, cols() - 4 - m.name.length); // 2 (mũi) + 2 (cách)
|
|
175
|
+
let desc = m.desc || "";
|
|
176
|
+
if (desc.length > budget) desc = desc.slice(0, Math.max(0, budget - 1)) + "…";
|
|
177
|
+
return (sel ? c.accent("❯ ") : " ") + (sel ? c.user(m.name) : m.name) + (desc ? c.dim(" " + desc) : "");
|
|
178
|
+
});
|
|
179
|
+
}
|
|
103
180
|
function rows() {
|
|
104
181
|
const r = [];
|
|
105
182
|
const top = topRow();
|
|
106
183
|
if (top !== null) r.push(top);
|
|
184
|
+
for (const mr of menuRows()) r.push(mr);
|
|
107
185
|
r.push(renderBar());
|
|
108
186
|
return r;
|
|
109
187
|
}
|
|
@@ -111,9 +189,12 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab } = {}) {
|
|
|
111
189
|
if (!drawn) return "\r";
|
|
112
190
|
return "\r" + (prevRows > 1 ? `${ESC}[${prevRows - 1}A` : "") + `${ESC}[J`;
|
|
113
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` : "");
|
|
114
195
|
function draw() {
|
|
115
|
-
const rs = rows();
|
|
116
|
-
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`);
|
|
117
198
|
prevRows = rs.length;
|
|
118
199
|
drawn = true;
|
|
119
200
|
}
|
|
@@ -123,7 +204,7 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab } = {}) {
|
|
|
123
204
|
let s = `${ESC}[?25l` + eraseSeq();
|
|
124
205
|
s += block;
|
|
125
206
|
if (!block.endsWith("\n")) s += "\n";
|
|
126
|
-
s += rs.join("\n") + `${ESC}[?25h`;
|
|
207
|
+
s += rs.join("\n") + placeCursor() + `${ESC}[?25h`;
|
|
127
208
|
w(s);
|
|
128
209
|
prevRows = rs.length;
|
|
129
210
|
drawn = true;
|
|
@@ -146,28 +227,78 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab } = {}) {
|
|
|
146
227
|
// ----- bàn phím: paste (bracketed) + heuristic chunk nhiều dòng -----
|
|
147
228
|
function pushText(str) {
|
|
148
229
|
if (!str) return;
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
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;
|
|
152
234
|
}
|
|
153
235
|
function pushPaste(content) {
|
|
154
236
|
const lines = content.split("\n").length;
|
|
155
|
-
if (lines >= 2)
|
|
156
|
-
|
|
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
|
|
157
242
|
}
|
|
158
243
|
function backspace() {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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]);
|
|
165
286
|
}
|
|
287
|
+
refreshMenu();
|
|
288
|
+
draw();
|
|
166
289
|
}
|
|
167
290
|
function submit() {
|
|
168
291
|
const full = fullText();
|
|
169
|
-
const echo = promptLabel +
|
|
170
|
-
|
|
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;
|
|
300
|
+
menu = [];
|
|
301
|
+
menuIdx = 0;
|
|
171
302
|
commit(echo); // hiện lại dòng đã gõ (paste = chip) vào scrollback
|
|
172
303
|
draw();
|
|
173
304
|
if (waiter) {
|
|
@@ -209,27 +340,65 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab } = {}) {
|
|
|
209
340
|
if (rest.startsWith(`${ESC}[200~`)) { inPaste = true; pasteAcc = ""; i += 6; continue; }
|
|
210
341
|
const ch = s[i];
|
|
211
342
|
if (ch === ESC) {
|
|
212
|
-
//
|
|
213
|
-
const
|
|
214
|
-
if (
|
|
215
|
-
|
|
216
|
-
|
|
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;
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
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;
|
|
217
372
|
continue;
|
|
218
373
|
}
|
|
219
374
|
if (ch === "\r" || ch === "\n") {
|
|
220
|
-
|
|
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
|
+
}
|
|
221
382
|
if (ch === "\r" && s[i + 1] === "\n") i++; // nuốt \n của \r\n
|
|
222
383
|
i++;
|
|
223
384
|
continue;
|
|
224
385
|
}
|
|
225
|
-
if (ch === "\x7f" || ch === "\b") { backspace(); draw(); i++; continue; }
|
|
386
|
+
if (ch === "\x7f" || ch === "\b") { backspace(); refreshMenu(); draw(); i++; continue; } // Backspace
|
|
226
387
|
if (ch === "\x03") { onInterrupt?.(); i++; continue; } // Ctrl+C
|
|
227
|
-
if (ch === "\
|
|
228
|
-
if (ch === "\
|
|
229
|
-
if (ch === "\
|
|
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(); }
|
|
395
|
+
i++;
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
230
398
|
if (ch < " ") { i++; continue; } // control khác — bỏ
|
|
231
399
|
// ký tự in được (kể cả UTF-8 vì stdin đã setEncoding utf8)
|
|
232
400
|
pushText(ch);
|
|
401
|
+
refreshMenu();
|
|
233
402
|
draw();
|
|
234
403
|
i++;
|
|
235
404
|
}
|
|
@@ -259,14 +428,7 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab } = {}) {
|
|
|
259
428
|
if (process.stdin.setRawMode) process.stdin.setRawMode(true);
|
|
260
429
|
process.stdin.resume();
|
|
261
430
|
w(`${ESC}[?2004h`); // bật bracketed paste
|
|
262
|
-
process.stdin.on("data", onData);
|
|
263
|
-
// shift+tab: dùng keypress decoder song song (chỉ để bắt phím này)
|
|
264
|
-
try {
|
|
265
|
-
readline.emitKeypressEvents(process.stdin);
|
|
266
|
-
process.stdin.on("keypress", (_s, key) => {
|
|
267
|
-
if (key && key.name === "tab" && key.shift) onShiftTab?.();
|
|
268
|
-
});
|
|
269
|
-
} catch {}
|
|
431
|
+
process.stdin.on("data", onData); // Shift+Tab (\x1b[Z) + mũi tên xử lý trong feedKeys
|
|
270
432
|
// vá stdout: mọi output → commit phía trên thanh
|
|
271
433
|
process.stdout.write = (chunk, enc, cb) => {
|
|
272
434
|
feedOutput(typeof chunk === "string" ? chunk : chunk.toString(typeof enc === "string" ? enc : "utf8"));
|
|
@@ -275,7 +437,7 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab } = {}) {
|
|
|
275
437
|
return true;
|
|
276
438
|
};
|
|
277
439
|
frameTimer = setInterval(() => {
|
|
278
|
-
if (statusText && !liveOut) {
|
|
440
|
+
if ((statusText || busy) && !liveOut) {
|
|
279
441
|
frame++;
|
|
280
442
|
draw();
|
|
281
443
|
}
|
|
@@ -289,6 +451,11 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab } = {}) {
|
|
|
289
451
|
statusText = text || null;
|
|
290
452
|
draw();
|
|
291
453
|
},
|
|
454
|
+
setBusy(on, label) {
|
|
455
|
+
busy = !!on;
|
|
456
|
+
if (label != null) busyLabel = label;
|
|
457
|
+
draw();
|
|
458
|
+
},
|
|
292
459
|
setPrompt(label) {
|
|
293
460
|
promptLabel = label || "";
|
|
294
461
|
draw();
|