@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noobdemon/noob-cli",
3
- "version": "1.2.0",
3
+ "version": "1.5.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
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} — run a shell command in the cwd
26
+ - run_command {"command": str, "timeout"?: int, "background"?: bool} — run a shell command in the cwd. A foreground command is killed after ~60s (override with "timeout" ms). For anything long-running or that never exits on its own — dev servers, watchers, \`python -m http.server\`, \`npm run dev\`, \`flask run\` — set "background": true: it starts the process, returns immediately, and keeps running WITHOUT blocking the next steps. Never start a server in the foreground (it will hang then be killed).
27
+ - bg_output {"id"?: int} — no id: list background processes + status; with id: show that process's captured output so far (poll this after starting a server to confirm it came up)
28
+ - kill_bg {"id": int} — stop a background process started with run_command background:true
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 handle(opts.prompt);
271
- persist();
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
- state.history.push({ role: "user", content: text });
570
+ const files = mentionedFiles(text);
571
+ const content = files.length
572
+ ? text + `\n\n[File người dùng nhắc tới bằng @: ${files.join(", ")} — đọc bằng read_file nếu cần.]`
573
+ : text;
574
+ state.history.push({ role: "user", content });
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
- const killer = setTimeout(() => child.kill(), timeout);
203
+ let timedOut = false;
204
+ const killer = setTimeout(() => {
205
+ timedOut = true;
206
+ child.kill();
207
+ }, timeout);
159
208
  child.stdout.on("data", (d) => (out += d));
160
209
  child.stderr.on("data", (d) => (out += d));
161
210
  child.on("error", (e) => {
@@ -164,11 +213,36 @@ export const TOOLS = {
164
213
  });
165
214
  child.on("close", (code) => {
166
215
  clearTimeout(killer);
167
- const tail = `\n[exit code ${code}]`;
216
+ const tail = timedOut
217
+ ? `\n[timed out after ${Math.round(timeout / 1000)}s — killed. If this is a server or other long-running task, re-run with {"background": true} instead.]`
218
+ : `\n[exit code ${code}]`;
168
219
  resolve(clip((out.trim() || "(no output)") + tail));
169
220
  });
170
221
  });
171
222
  },
223
+
224
+ // Đọc trạng thái + output đã gom của tiến trình nền. Không có id → liệt kê tất cả.
225
+ bg_output({ id } = {}) {
226
+ if (id == null) {
227
+ if (!bg.size) return "No background processes.";
228
+ return [...bg.entries()]
229
+ .map(([i, p]) => `#${i} ${p.exited ? `exited(code ${p.code})` : `running(pid ${p.child.pid})`} — ${p.command}`)
230
+ .join("\n");
231
+ }
232
+ const p = bg.get(id);
233
+ if (!p) return `No background process #${id}.`;
234
+ const status = p.exited ? `exited (code ${p.code})` : `running (pid ${p.child.pid}, ${Math.round((Date.now() - p.startedAt) / 1000)}s)`;
235
+ return clip(`#${id} ${status} — ${p.command}\n${p.out.trim() || "(no output yet)"}`);
236
+ },
237
+
238
+ // Dừng một tiến trình nền (kèm cây con trên Windows).
239
+ kill_bg({ id }) {
240
+ const p = bg.get(id);
241
+ if (!p) return `No background process #${id}.`;
242
+ killBgTree(p.child);
243
+ bg.delete(id);
244
+ return `Killed background process #${id} (${p.command}).`;
245
+ },
172
246
  };
173
247
 
174
248
  // Khớp old_string theo KHỐI DÒNG, bỏ qua khác biệt CRLF và khoảng trắng CUỐI
@@ -269,7 +343,11 @@ export function describe(name, input) {
269
343
  case "grep":
270
344
  return `grep "${input.pattern}"${input.path ? " in " + input.path : ""}`;
271
345
  case "run_command":
272
- return `$ ${input.command}`;
346
+ return (input.background ? "$ (nền) " : "$ ") + input.command;
347
+ case "bg_output":
348
+ return input.id != null ? `xem tiến trình nền #${input.id}` : "liệt kê tiến trình nền";
349
+ case "kill_bg":
350
+ return `dừng tiến trình nền #${input.id}`;
273
351
  default:
274
352
  return name;
275
353
  }
package/src/tui.js CHANGED
@@ -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
- let parts = []; // input: {type:'text',value} | {type:'paste',value,lines}
88
+ // Thanh nhập = mảng "ô" + con trỏ. Mỗi ô là 1 ký tự {c} hoặc 1 khối dán
89
+ // {paste, lines} (hiện thành chip, sửa/xoá theo cả khối). `cur` ∈ [0, len] là
90
+ // vị trí con trỏ → hỗ trợ ←/→/Home/End/Delete, chèn & xoá GIỮA dòng.
91
+ let cells = [];
92
+ let cur = 0;
83
93
  let waiter = null;
84
94
  const queue = [];
85
95
 
86
- // ----- inputchuỗi đầy đủ + bản hiển thị (paste = chip) -----
87
- const fullText = () => parts.map((p) => p.value).join("");
88
- const dispPlain = () => parts.map((p) => (p.type === "paste" ? `[pasted ${p.lines} lines]` : p.value)).join("");
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 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 budget = Math.max(4, cols() - visLen(promptLabel) - 1);
94
- const plain = dispPlain();
95
- if (plain.length <= budget) return promptLabel + dispColored();
96
- return promptLabel + "…" + plain.slice(plain.length - (budget - 1)); // giữ đuôi (chỗ đang gõ)
139
+ const promptW = visLen(promptLabel);
140
+ const budget = Math.max(4, cols() - promptW - 1);
141
+ const plains = cells.map(cellPlain);
142
+ let curCol = 0;
143
+ for (let k = 0; k < cur; k++) curCol += plains[k].length;
144
+ let total = 0;
145
+ for (const p of plains) total += p.length;
146
+ if (total <= budget) {
147
+ cursorScreenCol = promptW + curCol;
148
+ return promptLabel + coloredInput();
149
+ }
150
+ const plain = plains.join("");
151
+ let start = curCol > budget - 1 ? curCol - (budget - 1) : 0;
152
+ if (start > total - budget) start = total - budget;
153
+ if (start < 0) start = 0;
154
+ const end = Math.min(total, start + budget);
155
+ const arr = [...plain.slice(start, end)];
156
+ if (start > 0) arr[0] = "…";
157
+ if (end < total) arr[arr.length - 1] = "…";
158
+ cursorScreenCol = Math.min(promptW + budget, Math.max(promptW, promptW + (curCol - start)));
159
+ return promptLabel + arr.join("");
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 last = parts[parts.length - 1];
150
- if (last && last.type === "text") last.value += str;
151
- else parts.push({ type: "text", value: str });
230
+ const chars = [...str].map((ch) => ({ c: ch }));
231
+ cells.splice(cur, 0, ...chars); // chèn TẠI con trỏ (không chỉ cuối dòng)
232
+ cur += chars.length;
233
+ histPos = null;
152
234
  }
153
235
  function pushPaste(content) {
154
236
  const lines = content.split("\n").length;
155
- if (lines >= 2) parts.push({ type: "paste", value: content, lines });
156
- else pushText(content); // paste 1 dòng = gõ thẳng
237
+ if (lines >= 2) {
238
+ cells.splice(cur, 0, { paste: content, lines });
239
+ cur += 1;
240
+ histPos = null;
241
+ } else pushText(content); // paste 1 dòng = gõ thẳng
157
242
  }
158
243
  function backspace() {
159
- const last = parts[parts.length - 1];
160
- if (!last) return;
161
- if (last.type === "paste") parts.pop();
162
- else {
163
- last.value = [...last.value].slice(0, -1).join("");
164
- if (!last.value) parts.pop();
244
+ if (cur > 0) {
245
+ cells.splice(cur - 1, 1);
246
+ cur -= 1;
247
+ histPos = null;
248
+ }
249
+ }
250
+ function deleteForward() {
251
+ if (cur < cells.length) {
252
+ cells.splice(cur, 1);
253
+ histPos = null;
254
+ }
255
+ }
256
+ // null = ô dán (coi như ranh giới từ); ngược lại trả ký tự của ô.
257
+ const charAt = (k) => (cells[k] && cells[k].paste === undefined ? cells[k].c : null);
258
+ function moveWordLeft() {
259
+ while (cur > 0 && charAt(cur - 1) === " ") cur -= 1;
260
+ if (cur > 0 && charAt(cur - 1) === null) return void (cur -= 1); // qua 1 chip
261
+ while (cur > 0 && charAt(cur - 1) !== null && charAt(cur - 1) !== " ") cur -= 1;
262
+ }
263
+ function moveWordRight() {
264
+ while (cur < cells.length && charAt(cur) === " ") cur += 1;
265
+ if (cur < cells.length && charAt(cur) === null) return void (cur += 1);
266
+ while (cur < cells.length && charAt(cur) !== null && charAt(cur) !== " ") cur += 1;
267
+ }
268
+ function setInput(str) {
269
+ cells = [...str].map((ch) => ({ c: ch }));
270
+ cur = cells.length;
271
+ }
272
+ // ↑/↓ gọi lại các dòng đã gửi (chỉ khi menu gợi ý KHÔNG mở).
273
+ function histNav(dir) {
274
+ if (menu.length || !submitHistory.length) return;
275
+ if (dir < 0) {
276
+ histPos = histPos === null ? submitHistory.length - 1 : Math.max(0, histPos - 1);
277
+ setInput(submitHistory[histPos]);
278
+ } else {
279
+ if (histPos === null) return;
280
+ histPos += 1;
281
+ if (histPos > submitHistory.length - 1) {
282
+ histPos = null;
283
+ cells = [];
284
+ cur = 0;
285
+ } else setInput(submitHistory[histPos]);
165
286
  }
287
+ refreshMenu();
288
+ draw();
166
289
  }
167
290
  function submit() {
168
291
  const full = fullText();
169
- const echo = promptLabel + dispColored();
170
- parts = [];
292
+ const echo = promptLabel + coloredInput();
293
+ if (full.trim() && submitHistory[submitHistory.length - 1] !== full) {
294
+ submitHistory.push(full);
295
+ if (submitHistory.length > 200) submitHistory.shift();
296
+ }
297
+ histPos = null;
298
+ cells = [];
299
+ cur = 0;
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
- // esc seq (mũi tên…) bỏ qua v1
213
- const m = rest.match(/^\x1b\[[0-9;]*[A-Za-z~]/) || rest.match(/^\x1bO[A-Za-z]/);
214
- if (m) { i += m[0].length; continue; }
215
- if (rest.length < 3) { carry = rest; return; } // esc dở
216
- i += 1;
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
- submit();
375
+ if (menu.length && menuFill === "insert") {
376
+ acceptMenu(); // @file: chỉ CHÈN vào dòng, KHÔNG gửi — cho gõ tiếp
377
+ draw();
378
+ } else {
379
+ if (menu.length) setInput(menu[menuIdx].name); // /lệnh: điền rồi gửi
380
+ submit();
381
+ }
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 === "\x15") { parts = []; draw(); i++; continue; } // Ctrl+U xoá dòng
228
- if (ch === "\x04") { if (!fullText()) onEOF?.(); i++; continue; } // Ctrl+D
229
- if (ch === "\t") { i++; continue; } // tab thường — bỏ (shift+tab xử qua keypress)
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();