@noobdemon/noob-cli 1.2.0 → 1.4.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.4.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.
@@ -34,6 +35,13 @@ Available tools:
34
35
  - JSON in the tool block must be valid: escape newlines as \\n inside string values.
35
36
  - 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
37
 
38
+ # Self-memory (noob.md)
39
+ - 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.
40
+ - 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.
41
+ - 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.
42
+ - 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.
43
+ - Do NOT put transient chatter or secrets in noob.md.
44
+
37
45
  # Coding principles (Karpathy) — apply to EVERY code change
38
46
  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
47
  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 +138,23 @@ function filesLedger(history) {
130
138
  );
131
139
  }
132
140
 
141
+ // Chèn bộ nhớ noob.md (nếu có) vào prompt — đây là phần "tự học" mà noob đọc
142
+ // lại mỗi lượt. Không có file → nhắc model tự tạo khi rút ra điều đáng nhớ.
143
+ function memoryBlock() {
144
+ const mem = loadMemory();
145
+ if (!mem)
146
+ 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 đó.)";
147
+ return (
148
+ "# 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" +
149
+ mem +
150
+ "\n(Học thêm điều đáng nhớ → cập nhật noob.md bằng edit_file/write_file.)"
151
+ );
152
+ }
153
+
133
154
  // The proxy is stateless, so we serialize the whole transcript into one prompt.
134
155
  function buildPrompt(history) {
135
156
  const msgs = compact(history, MAX_PROMPT_CHARS);
136
- const parts = [SYSTEM, "", runtimeContext(), "", filesLedger(history), "", "=".repeat(60), "# CONVERSATION", ""];
157
+ const parts = [SYSTEM, "", runtimeContext(), "", memoryBlock(), "", filesLedger(history), "", "=".repeat(60), "# CONVERSATION", ""];
137
158
  for (const m of msgs) {
138
159
  if (m.role === "user") parts.push(`## USER\n${m.content}`);
139
160
  else if (m.role === "assistant") parts.push(`## ASSISTANT\n${m.content}`);
package/src/i18n.js CHANGED
@@ -58,6 +58,9 @@ export const t = {
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
60
  cmdKarpathy: "/karpathy [path] rà soát code theo 4 nguyên tắc Karpathy (/kc)",
61
+ cmdUltra: "/ultra <mục tiêu> tự hành: noob tự nghĩ & tự làm nhiệm vụ tới khi xong (/u)",
62
+ cmdLearn: "/learn [ghi chú] chưng cất bài học của phiên vào noob.md",
63
+ cmdMemory: "/memory xem bộ nhớ noob.md (/mem)",
61
64
  cmdClear: "/clear /new xoá ngữ cảnh (mở phiên mới, phiên cũ vẫn resume được)",
62
65
  cmdResume: "/resume [id] tiếp tục phiên cũ (không id = chọn từ danh sách)",
63
66
  cmdSessions: "/sessions liệt kê các phiên đã lưu",
@@ -90,6 +93,17 @@ export const t = {
90
93
  maxSteps: "_(đã dừng: chạm giới hạn số bước tool)_",
91
94
  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
95
 
96
+ // ultra (tự hành / self-quest) + bộ nhớ noob.md
97
+ ultraOn: "Ultra: BẬT — noob tự lập kế hoạch & tự làm tới khi xong (Ctrl+C để dừng).",
98
+ ultraDone: "Ultra: đã hoàn thành mục tiêu.",
99
+ ultraStopped: "Ultra: đã dừng.",
100
+ ultraMax: "Ultra: chạm giới hạn số vòng — dừng để bạn kiểm tra & ra lệnh tiếp.",
101
+ ultraNeedGoal: "Cần mục tiêu. Dùng: /ultra <mô tả mục tiêu>",
102
+ ultraQuest: (n) => `tự nghĩ nhiệm vụ kế tiếp (vòng ${n})…`,
103
+ learning: "đang chưng cất bài học vào noob.md…",
104
+ memoryEmpty: (p) => `Chưa có noob.md. noob sẽ tự tạo ở: ${p}`,
105
+ memoryStat: (n) => ` · ${n} dòng / ~200`,
106
+
93
107
  // sessions (lưu lịch sử + resume)
94
108
  sessionResumed: (id) => `Đã khôi phục phiên ${id}`,
95
109
  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
@@ -7,10 +7,44 @@ import { runTool, describe, DESTRUCTIVE } from "./tools.js";
7
7
  import { MODELS, PROVIDERS, findModel, providerColor, DEFAULT_MODEL } from "./models.js";
8
8
  import { c, banner, modelBadge, renderMarkdown, box } from "./ui.js";
9
9
  import { config } from "./config.js";
10
+ import { loadMemory, memoryPath } from "./memory.js";
10
11
  import { t } from "./i18n.js";
11
12
  import { checkLatest, runUpdate, CURRENT } from "./update.js";
12
13
  import * as sessions from "./sessions.js";
13
14
 
15
+ // Lệnh dùng cho autocomplete. Gõ "/l" → lọc các lệnh có "l" (login, logout,
16
+ // clear, models, yolo…); ↑/↓ chọn, Tab điền, Enter chạy mục đang sáng.
17
+ const SLASH = [
18
+ { name: "/help", desc: "danh sách lệnh" },
19
+ { name: "/model", desc: "đổi mô hình" },
20
+ { name: "/models", desc: "liệt kê mô hình" },
21
+ { name: "/merge", desc: "bật/tắt Merge AI" },
22
+ { name: "/search", desc: "bật/tắt tìm web" },
23
+ { name: "/chat", desc: "chế độ chat thường" },
24
+ { name: "/yolo", desc: "bật/tắt tự duyệt" },
25
+ { name: "/karpathy", desc: "rà soát code (Karpathy)" },
26
+ { name: "/ultra", desc: "tự hành: tự nghĩ & làm nhiệm vụ" },
27
+ { name: "/learn", desc: "chưng cất bài học vào noob.md" },
28
+ { name: "/memory", desc: "xem bộ nhớ noob.md" },
29
+ { name: "/login", desc: "đăng nhập bằng API key" },
30
+ { name: "/logout", desc: "đăng xuất" },
31
+ { name: "/usage", desc: "xem hạn mức key" },
32
+ { name: "/update", desc: "cập nhật noob" },
33
+ { name: "/clear", desc: "xoá ngữ cảnh / phiên mới" },
34
+ { name: "/resume", desc: "tiếp tục phiên cũ" },
35
+ { name: "/continue", desc: "tiếp tục phiên gần nhất" },
36
+ { name: "/sessions", desc: "liệt kê phiên đã lưu" },
37
+ { name: "/cwd", desc: "thư mục hiện tại" },
38
+ { name: "/status", desc: "trạng thái" },
39
+ { name: "/version", desc: "phiên bản" },
40
+ { name: "/exit", desc: "thoát" },
41
+ ];
42
+ function completeSlash(text) {
43
+ if (!text.startsWith("/") || /\s/.test(text)) return []; // chỉ gợi ý khi đang gõ tên lệnh
44
+ const q = text.slice(1).toLowerCase();
45
+ return SLASH.filter((cmd) => cmd.name.slice(1).toLowerCase().includes(q));
46
+ }
47
+
14
48
  export async function startRepl(opts = {}) {
15
49
  const state = {
16
50
  model: findModel(opts.model) || findModel(config.model) || findModel(DEFAULT_MODEL),
@@ -18,6 +52,7 @@ export async function startRepl(opts = {}) {
18
52
  history: [],
19
53
  autoApprove: new Set(),
20
54
  yolo: !!opts.yolo,
55
+ ultra: false, // chế độ tự hành (self-quest) đang chạy?
21
56
  };
22
57
 
23
58
  // Prompt = dòng trạng thái sống. Luôn phản ánh yolo + version theo thời gian
@@ -62,6 +97,7 @@ export async function startRepl(opts = {}) {
62
97
  tui.print(state.yolo ? c.err(" " + t.yoloOn) : c.ok(" " + t.yoloOff));
63
98
  tui.setPrompt(promptStr(false));
64
99
  },
100
+ completer: completeSlash,
65
101
  });
66
102
 
67
103
  // Dòng "tươi" cho prompt chính VÀ permission. KHÔNG đụng `pending`.
@@ -96,6 +132,10 @@ export async function startRepl(opts = {}) {
96
132
  if (abort) {
97
133
  abort.abort();
98
134
  abort = null;
135
+ if (state.ultra) {
136
+ state.ultra = false; // Ctrl+C cũng dừng vòng tự hành, không chỉ lượt hiện tại
137
+ console.log(c.tool(" " + t.ultraStopped));
138
+ }
99
139
  if (pending.length) {
100
140
  const n = pending.length;
101
141
  pending.length = 0; // ngắt lượt → xoá luôn tin đang xếp hàng
@@ -226,6 +266,72 @@ Cuối cùng: tóm tắt + danh sách sửa theo thứ tự ưu tiên. Thẳng t
226
266
  persist();
227
267
  }
228
268
 
269
+ // ── ULTRA: chế độ tự hành (self-quest) ────────────────────────────────────
270
+ // 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
271
+ // chứng — lặp tới khi model phát token ULTRA_DONE, chạm giới hạn vòng, hoặc
272
+ // người dùng Ctrl+C. Mỗi vòng là một lượt agent đầy đủ (dùng lại handle()).
273
+ const ULTRA_DONE = "<<ULTRA_DONE>>";
274
+ const MAX_QUESTS = 40;
275
+ const ultraStart = (goal) => `# CHẾ ĐỘ ULTRA (tự hành)
276
+ Mục tiêu tổng: ${goal}
277
+
278
+ Bạn sẽ TỰ lập kế hoạch và TỰ thực hiện từng bước tới khi hoàn thành, KHÔNG chờ người dùng xác nhận giữa chừng.
279
+ - Mỗi lượt: chọn nhiệm vụ con hợp lý kế tiếp, làm nó bằng tool (đọc/sửa file, chạy lệnh).
280
+ - Sau khi làm, tự kiểm chứng (build/test) khi hợp lý.
281
+ - Ghi tiến độ + điều học được vào noob.md để không quên giữa các bước.
282
+ - Khi và CHỈ KHI toàn bộ mục tiêu đã xong và đã kiểm chứng, kết thúc câu trả lời bằng đúng token ${ULTRA_DONE}.
283
+ - Gặp việc nguy hiểm/không thể đảo ngược hoặc thật sự bí → DỪNG, hỏi 1 câu rõ ràng (đừng phát ${ULTRA_DONE}).
284
+ Bắt đầu bằng kế hoạch ngắn (3–7 gạch đầu dòng) rồi làm bước đầu tiên.`;
285
+ const ultraContinue = (goal) => `Tiếp tục chế độ ULTRA — mục tiêu: ${goal}
286
+ Tự đánh giá còn thiếu gì, chọn nhiệm vụ con kế tiếp và làm tiếp bằng tool. Cập nhật noob.md nếu học được điều mới.
287
+ Khi toàn bộ mục tiêu đã xong và đã kiểm chứng, kết thúc bằng token ${ULTRA_DONE}.`;
288
+
289
+ async function runUltra(goal) {
290
+ if (!config.apiKey) return console.log(c.tool(" " + t.notLoggedIn));
291
+ if (!goal) return console.log(c.err(" " + t.ultraNeedGoal));
292
+ state.mode = "chat"; // tự hành chỉ chạy ở chế độ agent
293
+ state.ultra = true;
294
+ console.log(c.accent(" 🚀 " + t.ultraOn));
295
+ let answer = await handle(ultraStart(goal));
296
+ persist();
297
+ let i = 0;
298
+ while (state.ultra && i < MAX_QUESTS) {
299
+ if (!answer) break; // lượt bị ngắt/ lỗi → dừng tự hành, đừng quay vô ích
300
+ if (answer.includes(ULTRA_DONE)) {
301
+ console.log(c.ok(" ✓ " + t.ultraDone));
302
+ break;
303
+ }
304
+ i++;
305
+ console.log(c.accent(" ↻ " + t.ultraQuest(i)));
306
+ answer = await handle(ultraContinue(goal));
307
+ persist();
308
+ }
309
+ if (state.ultra && i >= MAX_QUESTS) console.log(c.tool(" " + t.ultraMax));
310
+ state.ultra = false;
311
+ }
312
+
313
+ // /learn [ghi chú] — bắt noob chưng cất điều đáng nhớ của phiên vào noob.md.
314
+ async function runLearn(arg) {
315
+ if (!config.apiKey) return console.log(c.tool(" " + t.notLoggedIn));
316
+ const note = arg ? `Người dùng nhấn mạnh cần nhớ: "${arg}"\n` : "";
317
+ 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.
318
+ - Đọ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).
319
+ - 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ở.
320
+ - 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).
321
+ - 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.
322
+ - 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ì.`;
323
+ console.log(c.tool(" 🧠 " + t.learning));
324
+ await handle(prompt);
325
+ persist();
326
+ }
327
+
328
+ function showMemory() {
329
+ const mem = loadMemory();
330
+ if (!mem) return console.log(c.dim(" " + t.memoryEmpty(memoryPath())));
331
+ console.log(box(mem.length > 1800 ? mem.slice(0, 1800) + "\n…" : mem, "noob.md", "#10b981"));
332
+ console.log(c.dim(" " + memoryPath() + t.memoryStat(mem.split("\n").length)));
333
+ }
334
+
229
335
  tui.start();
230
336
  banner();
231
337
  printStatus(state);
@@ -267,8 +373,11 @@ Cuối cùng: tóm tắt + danh sách sửa theo thứ tự ưu tiên. Thẳng t
267
373
 
268
374
  if (opts.prompt) {
269
375
  console.log(c.user(t.promptYou) + c.dim("› ") + opts.prompt);
270
- await handle(opts.prompt);
271
- persist();
376
+ if (opts.ultra) await runUltra(opts.prompt);
377
+ else {
378
+ await handle(opts.prompt);
379
+ persist();
380
+ }
272
381
  }
273
382
 
274
383
  // Main loop — runs until /exit, double Ctrl+C, or EOF. Never exits after a task.
@@ -397,6 +506,7 @@ Cuối cùng: tóm tắt + danh sách sửa theo thứ tự ưu tiên. Thẳng t
397
506
  // Lượt cuối không stream ra gì (vd model suy luận trả 1 cục) → in fallback.
398
507
  if ((!printer || !printer.started) && answer?.trim())
399
508
  printAnswer(answer, state.model.name, providerColor(state.model.provider));
509
+ return answer; // vòng ULTRA cần text này để dò token hoàn thành
400
510
  } catch (err) {
401
511
  stopSpin();
402
512
  if (err.name === "AbortError") return;
@@ -492,6 +602,17 @@ Cuối cùng: tóm tắt + danh sách sửa theo thứ tự ưu tiên. Thẳng t
492
602
  case "kc":
493
603
  await runKarpathy(arg);
494
604
  break;
605
+ case "ultra":
606
+ case "u":
607
+ await runUltra(arg);
608
+ break;
609
+ case "learn":
610
+ await runLearn(arg);
611
+ break;
612
+ case "memory":
613
+ case "mem":
614
+ showMemory();
615
+ break;
495
616
  case "login":
496
617
  doLogin(arg);
497
618
  break;
@@ -715,6 +836,9 @@ function printHelp() {
715
836
  " " + t.cmdChat,
716
837
  " " + t.cmdYolo,
717
838
  " " + t.cmdKarpathy,
839
+ " " + t.cmdUltra,
840
+ " " + t.cmdLearn,
841
+ " " + t.cmdMemory,
718
842
  " " + t.cmdLogin,
719
843
  " " + t.cmdLogout,
720
844
  " " + t.cmdUsage,
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
 
@@ -83,6 +83,14 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab } = {}) {
83
83
  let waiter = null;
84
84
  const queue = [];
85
85
 
86
+ // ----- autocomplete: gợi ý lệnh hiện phía trên thanh nhập -----
87
+ let menu = []; // [{name, desc}] khớp với những gì đang gõ
88
+ let menuIdx = 0; // mục đang chọn (mũi tên ↑/↓), Tab để điền
89
+ function refreshMenu() {
90
+ menu = completer ? completer(fullText()) || [] : [];
91
+ menuIdx = 0;
92
+ }
93
+
86
94
  // ----- input → chuỗi đầy đủ + bản hiển thị (paste = chip) -----
87
95
  const fullText = () => parts.map((p) => p.value).join("");
88
96
  const dispPlain = () => parts.map((p) => (p.type === "paste" ? `[pasted ${p.lines} lines]` : p.value)).join("");
@@ -100,10 +108,24 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab } = {}) {
100
108
  if (statusText) return c.dim(FRAMES[frame % FRAMES.length] + " ") + statusText;
101
109
  return null;
102
110
  }
111
+ function menuRows() {
112
+ if (!menu.length) return [];
113
+ const MAXV = 8; // cửa sổ cuộn quanh mục đang chọn
114
+ let start = 0;
115
+ if (menu.length > MAXV) start = Math.min(Math.max(0, menuIdx - 3), menu.length - MAXV);
116
+ return menu.slice(start, start + MAXV).map((m, k) => {
117
+ const sel = start + k === menuIdx;
118
+ const budget = Math.max(0, cols() - 4 - m.name.length); // 2 (mũi) + 2 (cách)
119
+ let desc = m.desc || "";
120
+ if (desc.length > budget) desc = desc.slice(0, Math.max(0, budget - 1)) + "…";
121
+ return (sel ? c.accent("❯ ") : " ") + (sel ? c.user(m.name) : m.name) + (desc ? c.dim(" " + desc) : "");
122
+ });
123
+ }
103
124
  function rows() {
104
125
  const r = [];
105
126
  const top = topRow();
106
127
  if (top !== null) r.push(top);
128
+ for (const mr of menuRows()) r.push(mr);
107
129
  r.push(renderBar());
108
130
  return r;
109
131
  }
@@ -168,6 +190,8 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab } = {}) {
168
190
  const full = fullText();
169
191
  const echo = promptLabel + dispColored();
170
192
  parts = [];
193
+ menu = [];
194
+ menuIdx = 0;
171
195
  commit(echo); // hiện lại dòng đã gõ (paste = chip) vào scrollback
172
196
  draw();
173
197
  if (waiter) {
@@ -209,7 +233,19 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab } = {}) {
209
233
  if (rest.startsWith(`${ESC}[200~`)) { inPaste = true; pasteAcc = ""; i += 6; continue; }
210
234
  const ch = s[i];
211
235
  if (ch === ESC) {
212
- // esc seq (mũi tên…) bỏ quav1
236
+ // Shift+Tab = CSI Z (\x1b[Z). Bắt TRỰC TIẾPđây cho chắc — decoder
237
+ // keypress song song trước đây không nổ trên một số terminal Windows.
238
+ if (rest.startsWith(`${ESC}[Z`)) { onShiftTab?.(); i += 3; continue; }
239
+ // Mũi tên ↑/↓ điều hướng menu gợi ý lệnh (↑=A, ↓=B). ←/→ (C/D) nuốt.
240
+ const arrow = rest.match(/^\x1b(?:\[|O)([ABCD])/);
241
+ if (arrow) {
242
+ if (menu.length && (arrow[1] === "A" || arrow[1] === "B")) {
243
+ menuIdx = arrow[1] === "A" ? (menuIdx - 1 + menu.length) % menu.length : (menuIdx + 1) % menu.length;
244
+ draw();
245
+ }
246
+ i += arrow[0].length;
247
+ continue;
248
+ }
213
249
  const m = rest.match(/^\x1b\[[0-9;]*[A-Za-z~]/) || rest.match(/^\x1bO[A-Za-z]/);
214
250
  if (m) { i += m[0].length; continue; }
215
251
  if (rest.length < 3) { carry = rest; return; } // esc dở
@@ -217,19 +253,25 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab } = {}) {
217
253
  continue;
218
254
  }
219
255
  if (ch === "\r" || ch === "\n") {
256
+ if (menu.length) parts = [{ type: "text", value: menu[menuIdx].name }]; // Enter chọn mục đang sáng
220
257
  submit();
221
258
  if (ch === "\r" && s[i + 1] === "\n") i++; // nuốt \n của \r\n
222
259
  i++;
223
260
  continue;
224
261
  }
225
- if (ch === "\x7f" || ch === "\b") { backspace(); draw(); i++; continue; }
262
+ if (ch === "\x7f" || ch === "\b") { backspace(); refreshMenu(); draw(); i++; continue; }
226
263
  if (ch === "\x03") { onInterrupt?.(); i++; continue; } // Ctrl+C
227
- if (ch === "\x15") { parts = []; draw(); i++; continue; } // Ctrl+U xoá dòng
264
+ if (ch === "\x15") { parts = []; refreshMenu(); draw(); i++; continue; } // Ctrl+U xoá dòng
228
265
  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)
266
+ if (ch === "\t") { // Tab điền lệnh đang chọn + dấu cách (để tham số), đóng menu
267
+ if (menu.length) { parts = [{ type: "text", value: menu[menuIdx].name + " " }]; menu = []; draw(); }
268
+ i++;
269
+ continue;
270
+ }
230
271
  if (ch < " ") { i++; continue; } // control khác — bỏ
231
272
  // ký tự in được (kể cả UTF-8 vì stdin đã setEncoding utf8)
232
273
  pushText(ch);
274
+ refreshMenu();
233
275
  draw();
234
276
  i++;
235
277
  }
@@ -259,14 +301,7 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab } = {}) {
259
301
  if (process.stdin.setRawMode) process.stdin.setRawMode(true);
260
302
  process.stdin.resume();
261
303
  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 {}
304
+ process.stdin.on("data", onData); // Shift+Tab (\x1b[Z) + mũi tên xử lý trong feedKeys
270
305
  // vá stdout: mọi output → commit phía trên thanh
271
306
  process.stdout.write = (chunk, enc, cb) => {
272
307
  feedOutput(typeof chunk === "string" ? chunk : chunk.toString(typeof enc === "string" ? enc : "utf8"));