@noobdemon/noob-cli 1.1.4 → 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.1.4",
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
@@ -1,17 +1,50 @@
1
- import readline from "node:readline";
2
1
  import process from "node:process";
3
- import ora from "ora";
4
2
  import chalk from "chalk";
3
+ import { createTui } from "./tui.js";
5
4
  import { runAgent } from "./agent.js";
6
5
  import { stream, usage, ApiError } from "./api.js";
7
6
  import { runTool, describe, DESTRUCTIVE } from "./tools.js";
8
7
  import { MODELS, PROVIDERS, findModel, providerColor, DEFAULT_MODEL } from "./models.js";
9
8
  import { c, banner, modelBadge, renderMarkdown, box } from "./ui.js";
10
9
  import { config } from "./config.js";
10
+ import { loadMemory, memoryPath } from "./memory.js";
11
11
  import { t } from "./i18n.js";
12
12
  import { checkLatest, runUpdate, CURRENT } from "./update.js";
13
13
  import * as sessions from "./sessions.js";
14
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
+
15
48
  export async function startRepl(opts = {}) {
16
49
  const state = {
17
50
  model: findModel(opts.model) || findModel(config.model) || findModel(DEFAULT_MODEL),
@@ -19,6 +52,7 @@ export async function startRepl(opts = {}) {
19
52
  history: [],
20
53
  autoApprove: new Set(),
21
54
  yolo: !!opts.yolo,
55
+ ultra: false, // chế độ tự hành (self-quest) đang chạy?
22
56
  };
23
57
 
24
58
  // Prompt = dòng trạng thái sống. Luôn phản ánh yolo + version theo thời gian
@@ -40,106 +74,36 @@ export async function startRepl(opts = {}) {
40
74
  // nguyên reader đang chờ (người dùng chỉ thấy prompt hiện lại). Chỉ dừng thật
41
75
  // khi: /exit, Ctrl+C hai lần, hoặc EOF thật (stdin piped đã cạn / 'close' dồn
42
76
  // dập nghĩa là stdin đã mất).
43
- let rl;
44
- let closed = false; // đã ngừng đọc vĩnh viễn
45
- let exiting = false; // ta chủ động thoát (/exit, Ctrl+C ×2)
46
- let lastPrompt = ""; // áp lại sau khi dựng lại interface
47
- // Hàng đợi tin nhắn (giống Claude Code): khi model đang chạy, thêm câu hỏi
48
- // → xếp vào `pending`; xong turn, main loop tự lấy câu kế tiếp gửi lên. CHỈ
49
- // main loop tiêu thụ `pending`. Câu trả lời permission đi qua `waiter` riêng,
50
- // nên tin xếp hàng KHÔNG bị nhầm thành câu trả lời "cho phép?".
51
- const pending = [];
52
- let waiter = null; // resolver đang chờ một dòng tươi (prompt / permission)
53
- let closeAt = 0;
54
-
55
- function deliver(line) {
56
- if (waiter) {
57
- const w = waiter;
58
- waiter = null;
59
- w(line);
60
- return;
61
- }
62
- // Không ai đang hỏi → tin xếp hàng. Nếu đang chạy task → sẽ CHÈN cho AI ở
63
- // bước kế tiếp (steering). Nếu rảnh gửi như lượt mới khi tới phiên.
64
- pending.push(line);
65
- if (process.stdin.isTTY)
66
- console.log(abort ? c.user(" " + t.steerWillInject(truncate(line, 60))) : c.dim(" " + t.queued(pending.length, truncate(line, 60))));
67
- }
68
- function endInput() {
69
- closed = true;
70
- if (waiter) {
71
- const w = waiter;
72
- waiter = null;
73
- w(null);
74
- }
75
- }
76
- function buildRl() {
77
- const r = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: "" });
78
- r.on("line", deliver);
79
- // readline ở chế độ terminal phát 'SIGINT' của RIÊNG nó (không cho process
80
- // SIGINT chạy) — nối vào cùng một handler. interrupt() được hoist nên gọi
81
- // được dù khai báo phía dưới.
82
- r.on("SIGINT", () => interrupt());
83
- r.on("close", () => {
84
- if (exiting) return endInput(); // ta chủ động thoát
85
- // EOF THẬT: stdin đã end/destroy (Ctrl+Z trên Windows, Ctrl+D trên *nix),
86
- // hoặc stdin không phải TTY (piped) đã đọc hết. Chỉ lúc đó mới dừng.
87
- if (!process.stdin.isTTY || process.stdin.readableEnded || process.stdin.destroyed) {
88
- return endInput();
89
- }
90
- // 'close' BẤT THƯỜNG trên một TTY còn sống (tranh chấp console khi
91
- // paste/đa dòng, tiến trình con, v.v.) → KHÔNG BAO GIỜ thoát. Dựng lại
92
- // interface và hiện lại prompt; reader đang chờ vẫn được giữ nguyên. Nếu
93
- // close dồn dập thì hoãn 50ms để khỏi quay CPU — nhưng vẫn sống.
94
- const now = Date.now();
95
- const fast = now - closeAt < 50;
96
- closeAt = now;
97
- const rebuild = () => {
98
- rl = buildRl();
99
- if (lastPrompt) {
100
- rl.setPrompt(lastPrompt);
101
- rl.prompt();
102
- }
103
- };
104
- if (fast) setTimeout(rebuild, 50);
105
- else rebuild();
106
- });
107
- return r;
108
- }
109
- rl = buildRl();
77
+ let closed = false;
78
+ let exiting = false;
79
+ const pending = []; // hàng đợi tin nhắn (steering giữa task / lượt mới)
80
+
81
+ // Toàn bộ I/O qua `tui`: thanh nhập ghim đáy, output cuộn trên, paste nhiều
82
+ // dòng "[pasted N lines]" nhưng gửi đủ. Không phải TTY / NOOB_TUI=0 dumb
83
+ // mode = đọc dòng thường (an toàn pipe/CI/terminal lạ).
84
+ const tui = createTui({
85
+ onLine: (line) => {
86
+ // Submit khi KHÔNG read() đang chờ = tin xếp hàng. Đang chạy task → sẽ
87
+ // CHÈN cho AI ở bước kế tiếp (steering); rảnh → gửi như lượt mới.
88
+ pending.push(line);
89
+ tui.print(abort ? c.user(" " + t.steerWillInject(truncate(line, 60))) : c.dim(" " + t.queued(pending.length, truncate(line, 60))));
90
+ },
91
+ onInterrupt: () => interrupt(),
92
+ onEOF: () => {
93
+ closed = true;
94
+ },
95
+ onShiftTab: () => {
96
+ state.yolo = !state.yolo;
97
+ tui.print(state.yolo ? c.err(" " + t.yoloOn) : c.ok(" " + t.yoloOff));
98
+ tui.setPrompt(promptStr(false));
99
+ },
100
+ completer: completeSlash,
101
+ });
110
102
 
111
- function nextLine() {
112
- if (closed) return Promise.resolve(null);
113
- return new Promise((res) => (waiter = res));
114
- }
115
- // Dòng "tươi": dùng cho prompt chính VÀ cho permission. KHÔNG đụng tới
116
- // `pending` (hàng đợi tin nhắn) — chỉ main loop mới rút từ `pending`.
103
+ // Dòng "tươi" cho prompt chính VÀ permission. KHÔNG đụng `pending`.
117
104
  function ask(prompt) {
118
105
  if (closed) return Promise.resolve(null);
119
- lastPrompt = prompt;
120
- rl.setPrompt(prompt);
121
- rl.prompt();
122
- return nextLine();
123
- }
124
-
125
- // Shift+Tab — bật/tắt yolo nhanh (best-effort; gắn vào stdin nên sống qua mọi
126
- // lần dựng lại rl; bọc try/catch để không bao giờ làm hỏng input).
127
- if (process.stdin.isTTY) {
128
- try {
129
- readline.emitKeypressEvents(process.stdin);
130
- process.stdin.on("keypress", (_str, key) => {
131
- if (!key) return;
132
- // Ctrl+C dạng phím thô — ở raw mode, Ctrl+C KHÔNG tự thành SIGINT nữa.
133
- if (key.ctrl && key.name === "c") return interrupt();
134
- if (key.name !== "tab" || !key.shift) return;
135
- state.yolo = !state.yolo;
136
- console.log(state.yolo ? c.err(" " + t.yoloOn) : c.ok(" " + t.yoloOff));
137
- rl.setPrompt(promptStr(false)); // cập nhật dòng trạng thái ngay lập tức
138
- rl.prompt(true);
139
- });
140
- } catch {
141
- /* không có Shift+Tab cũng được — vẫn dùng /yolo */
142
- }
106
+ return tui.read(prompt);
143
107
  }
144
108
 
145
109
  // NOOB_DEBUG=1: vạch trần đường thoát thật sự (xác nhận/loại bỏ giả thuyết
@@ -168,6 +132,10 @@ export async function startRepl(opts = {}) {
168
132
  if (abort) {
169
133
  abort.abort();
170
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
+ }
171
139
  if (pending.length) {
172
140
  const n = pending.length;
173
141
  pending.length = 0; // ngắt lượt → xoá luôn tin đang xếp hàng
@@ -179,7 +147,7 @@ export async function startRepl(opts = {}) {
179
147
  clearTimeout(sigintTimer);
180
148
  sigintTimer = null;
181
149
  }
182
- if (!closed) rl.prompt(true);
150
+ if (!closed) tui.setPrompt(promptStr(false));
183
151
  return;
184
152
  }
185
153
 
@@ -189,6 +157,7 @@ export async function startRepl(opts = {}) {
189
157
  exiting = true;
190
158
  persist();
191
159
  console.log(c.dim("\n " + t.bye));
160
+ tui.close(); // khôi phục terminal (raw mode/paste/stdout) trước khi thoát
192
161
  process.exit(0);
193
162
  }
194
163
  // lần 1 → vũ trang, đếm 1.5s
@@ -198,7 +167,7 @@ export async function startRepl(opts = {}) {
198
167
  sigintArmed = false;
199
168
  sigintTimer = null;
200
169
  }, 1500);
201
- if (!closed) rl.prompt(true);
170
+ if (!closed) tui.setPrompt(promptStr(false));
202
171
  }
203
172
  process.on("SIGINT", interrupt);
204
173
 
@@ -209,11 +178,11 @@ export async function startRepl(opts = {}) {
209
178
  process.on("uncaughtException", (err) => {
210
179
  if (abort) { abort.abort(); abort = null; }
211
180
  console.log(c.err("\n ✗ lỗi: " + (err?.message || err)));
212
- if (!closed) rl.prompt(true);
181
+ if (!closed) tui.setPrompt(promptStr(false));
213
182
  });
214
183
  process.on("unhandledRejection", (err) => {
215
184
  console.log(c.err("\n ✗ lỗi nền: " + (err?.message || err)));
216
- if (!closed) rl.prompt(true);
185
+ if (!closed) tui.setPrompt(promptStr(false));
217
186
  });
218
187
 
219
188
  // ── phiên (session): lưu lịch sử + resume giống Claude Code ───────────────
@@ -297,6 +266,73 @@ Cuối cùng: tóm tắt + danh sách sửa theo thứ tự ưu tiên. Thẳng t
297
266
  persist();
298
267
  }
299
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
+
335
+ tui.start();
300
336
  banner();
301
337
  printStatus(state);
302
338
  if (!config.apiKey) console.log("\n" + c.tool(" " + t.notLoggedIn) + "\n");
@@ -337,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
337
373
 
338
374
  if (opts.prompt) {
339
375
  console.log(c.user(t.promptYou) + c.dim("› ") + opts.prompt);
340
- await handle(opts.prompt);
341
- persist();
376
+ if (opts.ultra) await runUltra(opts.prompt);
377
+ else {
378
+ await handle(opts.prompt);
379
+ persist();
380
+ }
342
381
  }
343
382
 
344
383
  // Main loop — runs until /exit, double Ctrl+C, or EOF. Never exits after a task.
@@ -347,9 +386,8 @@ Cuối cùng: tóm tắt + danh sách sửa theo thứ tự ưu tiên. Thẳng t
347
386
  if (pending.length) {
348
387
  // Có tin xếp hàng → tự gửi câu kế tiếp (không chờ gõ).
349
388
  input = (pending.shift() ?? "").trim();
350
- if (process.stdin.isTTY && input) console.log(promptStr() + input);
351
389
  } else {
352
- const raw = await ask(promptStr());
390
+ const raw = await ask(promptStr(false));
353
391
  if (raw == null) break; // stdin fully closed and drained
354
392
  input = raw.trim();
355
393
  }
@@ -370,7 +408,7 @@ Cuối cùng: tóm tắt + danh sách sửa theo thứ tự ưu tiên. Thẳng t
370
408
  }
371
409
  }
372
410
  exiting = true;
373
- rl.close();
411
+ tui.close();
374
412
  process.exit(0);
375
413
 
376
414
  // ── turn handler ─────────────────────────────────────────────────────────
@@ -380,27 +418,22 @@ Cuối cùng: tóm tắt + danh sách sửa theo thứ tự ưu tiên. Thẳng t
380
418
  return;
381
419
  }
382
420
  abort = new AbortController();
383
- // discardStdin:false TỐI QUAN TRỌNG. Mặc định ora chiếm stdin (raw mode +
384
- // pause) để "nuốt" input khi quay. Trên Windows nó KHÔNG khôi phục sạch khi
385
- // stop → stdin chết → prompt "cho phép?" hiện ra rồi event loop cạn → CLI tự
386
- // out; và Ctrl+C không thành SIGINT (phải spam mới thoát). Tắt hẳn để ora
387
- // đừng đụng stdin — readline tự quản.
388
- const spinner = ora({ color: "magenta", spinner: "dots", discardStdin: false });
421
+ // spinner: status hiển thị qua tui (thanh dưới), tui tự animate khung xoay
389
422
  const t0 = Date.now();
390
423
  let timer = null;
391
424
  const tick = (label) => {
392
425
  const elapsed = ((Date.now() - t0) / 1000).toFixed(0);
393
- spinner.text = c.dim(`${label}… ${elapsed}s`);
426
+ tui.status(c.dim(`${label}… ${elapsed}s`));
394
427
  };
395
428
  const stopSpin = () => {
396
429
  if (timer) {
397
430
  clearInterval(timer);
398
431
  timer = null;
399
432
  }
400
- if (spinner.isSpinning) spinner.stop();
433
+ tui.status(null);
401
434
  };
402
435
  const startSpin = (label) => {
403
- if (!spinner.isSpinning) spinner.start();
436
+ // (tui hiện status khi tick gọi)
404
437
  if (!timer) timer = setInterval(() => tick(label), 200);
405
438
  };
406
439
 
@@ -415,7 +448,7 @@ Cuối cùng: tóm tắt + danh sách sửa theo thứ tự ưu tiên. Thẳng t
415
448
  message: text,
416
449
  signal: abort.signal,
417
450
  onStatus: (s) => {
418
- if (!printer.started) spinner.text = c.dim(" " + s);
451
+ if (!printer.started) tui.status(c.dim(" " + s));
419
452
  },
420
453
  onDelta: (d) => {
421
454
  stopSpin();
@@ -473,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
473
506
  // Lượt cuối không stream ra gì (vd model suy luận trả 1 cục) → in fallback.
474
507
  if ((!printer || !printer.started) && answer?.trim())
475
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
476
510
  } catch (err) {
477
511
  stopSpin();
478
512
  if (err.name === "AbortError") return;
@@ -499,14 +533,14 @@ Cuối cùng: tóm tắt + danh sách sửa theo thứ tự ưu tiên. Thẳng t
499
533
  }
500
534
  }
501
535
 
502
- const sp = ora({ text: c.dim(" " + t.running), color: "yellow", discardStdin: false }).start();
536
+ tui.status(c.dim(" " + t.running));
503
537
  try {
504
538
  const result = await runTool(name, input);
505
- sp.stop();
539
+ tui.status(null);
506
540
  console.log(c.ok(" ✓ ") + c.dim(firstLine(result)));
507
541
  return { allow: true, result };
508
542
  } catch (err) {
509
- sp.stop();
543
+ tui.status(null);
510
544
  console.log(c.err(" ✗ " + err.message));
511
545
  return { allow: true, result: "ERROR: " + err.message };
512
546
  }
@@ -568,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
568
602
  case "kc":
569
603
  await runKarpathy(arg);
570
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;
571
616
  case "login":
572
617
  doLogin(arg);
573
618
  break;
@@ -586,7 +631,7 @@ Cuối cùng: tóm tắt + danh sách sửa theo thứ tự ưu tiên. Thẳng t
586
631
  persist(); // giữ lại phiên cũ trên đĩa
587
632
  state.history = [];
588
633
  startFresh(); // phiên mới (phiên cũ vẫn resume được)
589
- console.clear();
634
+ if (!tui.tty) console.clear();
590
635
  banner();
591
636
  printStatus(state);
592
637
  console.log(c.dim(" " + t.ctxCleared + "\n"));
@@ -641,14 +686,14 @@ Cuối cùng: tóm tắt + danh sách sửa theo thứ tự ưu tiên. Thẳng t
641
686
 
642
687
  async function showUsage() {
643
688
  if (!config.apiKey) return console.log(c.tool(" " + t.notLoggedIn));
644
- const sp = ora({ text: c.dim(" ..."), color: "magenta", discardStdin: false }).start();
689
+ tui.status(c.dim(" ..."));
645
690
  try {
646
691
  const u = await usage();
647
- sp.stop();
692
+ tui.status(null);
648
693
  if (!u.ok) return printError(new ApiError(t.errInvalidKey, { code: u.error }));
649
694
  printUsage(u);
650
695
  } catch (err) {
651
- sp.stop();
696
+ tui.status(null);
652
697
  printError(err);
653
698
  }
654
699
  }
@@ -791,6 +836,9 @@ function printHelp() {
791
836
  " " + t.cmdChat,
792
837
  " " + t.cmdYolo,
793
838
  " " + t.cmdKarpathy,
839
+ " " + t.cmdUltra,
840
+ " " + t.cmdLearn,
841
+ " " + t.cmdMemory,
794
842
  " " + t.cmdLogin,
795
843
  " " + t.cmdLogout,
796
844
  " " + t.cmdUsage,
package/src/tui.js ADDED
@@ -0,0 +1,347 @@
1
+ // Bộ render kiểu TUI: thanh nhập GHIM ở đáy, output cuộn phía trên, không nhấp
2
+ // nháy. Hỗ trợ paste nhiều dòng → hiện "[pasted N lines]" nhưng GỬI toàn bộ nội
3
+ // dung. Hoạt động bằng cách "vá" process.stdout.write: mọi output (console.log,
4
+ // stream token, box…) tự được commit phía TRÊN thanh nhập — nên repl gần như
5
+ // không phải đổi chỗ in.
6
+ //
7
+ // Bật/tắt: TTY thật → chế độ giàu; không phải TTY hoặc NOOB_TUI=0 → chế độ "dumb"
8
+ // (đọc dòng đơn giản, in thẳng) để khỏi vỡ ở terminal lạ / pipe / CI.
9
+ import readline from "node:readline";
10
+ import { c } from "./ui.js";
11
+
12
+ const ESC = "\x1b";
13
+ const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
14
+ const visLen = (s) => s.replace(ANSI_RE, "").length;
15
+ const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
16
+
17
+ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer } = {}) {
18
+ const tty = !!(process.stdout.isTTY && process.stdin.isTTY) && process.env.NOOB_TUI !== "0";
19
+ const cols = () => process.stdout.columns || 80;
20
+
21
+ // ── chế độ DUMB (không TTY / tắt TUI): readline thường ───────────────────
22
+ if (!tty) {
23
+ let rl = null;
24
+ let waiter = null;
25
+ const queue = [];
26
+ let closed = false;
27
+ const build = () => {
28
+ const r = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: "" });
29
+ r.on("line", (line) => {
30
+ if (waiter) {
31
+ const w = waiter;
32
+ waiter = null;
33
+ w(line);
34
+ } else if (onLine) onLine(line);
35
+ else queue.push(line);
36
+ });
37
+ r.on("close", () => {
38
+ closed = true;
39
+ if (waiter) {
40
+ const w = waiter;
41
+ waiter = null;
42
+ w(null);
43
+ }
44
+ onEOF?.();
45
+ });
46
+ return r;
47
+ };
48
+ return {
49
+ tty: false,
50
+ start() {
51
+ rl = build();
52
+ process.on("SIGINT", () => onInterrupt?.());
53
+ },
54
+ print() {},
55
+ status() {},
56
+ setPrompt() {},
57
+ read() {
58
+ if (queue.length) return Promise.resolve(queue.shift());
59
+ if (closed) return Promise.resolve(null);
60
+ return new Promise((res) => (waiter = res));
61
+ },
62
+ close() {
63
+ try {
64
+ rl?.close();
65
+ } catch {}
66
+ },
67
+ };
68
+ }
69
+
70
+ // ── chế độ GIÀU (TTY) ─────────────────────────────────────────────────────
71
+ const realWrite = process.stdout.write.bind(process.stdout);
72
+ const w = (s) => realWrite(s);
73
+
74
+ let liveOut = ""; // dòng output dở dang (chưa có '\n') hiện ngay trên thanh
75
+ let statusText = null; // text spinner khi đang nghĩ
76
+ let frame = 0;
77
+ let frameTimer = null;
78
+ let prevRows = 0;
79
+ let drawn = false;
80
+
81
+ let promptLabel = "";
82
+ let parts = []; // input: {type:'text',value} | {type:'paste',value,lines}
83
+ let waiter = null;
84
+ const queue = [];
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
+
94
+ // ----- input → chuỗi đầy đủ + bản hiển thị (paste = chip) -----
95
+ const fullText = () => parts.map((p) => p.value).join("");
96
+ const dispPlain = () => parts.map((p) => (p.type === "paste" ? `[pasted ${p.lines} lines]` : p.value)).join("");
97
+ const dispColored = () =>
98
+ parts.map((p) => (p.type === "paste" ? c.dim(`[pasted ${p.lines} lines]`) : p.value)).join("");
99
+
100
+ function renderBar() {
101
+ const budget = Math.max(4, cols() - visLen(promptLabel) - 1);
102
+ const plain = dispPlain();
103
+ if (plain.length <= budget) return promptLabel + dispColored();
104
+ return promptLabel + "…" + plain.slice(plain.length - (budget - 1)); // giữ đuôi (chỗ đang gõ)
105
+ }
106
+ function topRow() {
107
+ if (liveOut) return liveOut.slice(0, cols());
108
+ if (statusText) return c.dim(FRAMES[frame % FRAMES.length] + " ") + statusText;
109
+ return null;
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
+ }
124
+ function rows() {
125
+ const r = [];
126
+ const top = topRow();
127
+ if (top !== null) r.push(top);
128
+ for (const mr of menuRows()) r.push(mr);
129
+ r.push(renderBar());
130
+ return r;
131
+ }
132
+ function eraseSeq() {
133
+ if (!drawn) return "\r";
134
+ return "\r" + (prevRows > 1 ? `${ESC}[${prevRows - 1}A` : "") + `${ESC}[J`;
135
+ }
136
+ function draw() {
137
+ const rs = rows();
138
+ w(`${ESC}[?25l` + eraseSeq() + rs.join("\n") + `${ESC}[?25h`);
139
+ prevRows = rs.length;
140
+ drawn = true;
141
+ }
142
+ // In khối text VĨNH VIỄN phía trên thanh, rồi vẽ lại thanh.
143
+ function commit(block) {
144
+ const rs = rows();
145
+ let s = `${ESC}[?25l` + eraseSeq();
146
+ s += block;
147
+ if (!block.endsWith("\n")) s += "\n";
148
+ s += rs.join("\n") + `${ESC}[?25h`;
149
+ w(s);
150
+ prevRows = rs.length;
151
+ drawn = true;
152
+ }
153
+
154
+ // Mọi stdout đi qua đây: tách dòng hoàn chỉnh → commit; phần dở → liveOut.
155
+ function feedOutput(text) {
156
+ let buf = liveOut + text;
157
+ let nl;
158
+ const done = [];
159
+ while ((nl = buf.indexOf("\n")) !== -1) {
160
+ done.push(buf.slice(0, nl));
161
+ buf = buf.slice(nl + 1);
162
+ }
163
+ liveOut = buf;
164
+ if (done.length) commit(done.join("\n"));
165
+ else draw();
166
+ }
167
+
168
+ // ----- bàn phím: paste (bracketed) + heuristic chunk nhiều dòng -----
169
+ function pushText(str) {
170
+ if (!str) return;
171
+ const last = parts[parts.length - 1];
172
+ if (last && last.type === "text") last.value += str;
173
+ else parts.push({ type: "text", value: str });
174
+ }
175
+ function pushPaste(content) {
176
+ const lines = content.split("\n").length;
177
+ if (lines >= 2) parts.push({ type: "paste", value: content, lines });
178
+ else pushText(content); // paste 1 dòng = gõ thẳng
179
+ }
180
+ function backspace() {
181
+ const last = parts[parts.length - 1];
182
+ if (!last) return;
183
+ if (last.type === "paste") parts.pop();
184
+ else {
185
+ last.value = [...last.value].slice(0, -1).join("");
186
+ if (!last.value) parts.pop();
187
+ }
188
+ }
189
+ function submit() {
190
+ const full = fullText();
191
+ const echo = promptLabel + dispColored();
192
+ parts = [];
193
+ menu = [];
194
+ menuIdx = 0;
195
+ commit(echo); // hiện lại dòng đã gõ (paste = chip) vào scrollback
196
+ draw();
197
+ if (waiter) {
198
+ const wr = waiter;
199
+ waiter = null;
200
+ wr(full);
201
+ } else if (onLine) onLine(full);
202
+ else queue.push(full);
203
+ }
204
+
205
+ let carry = "";
206
+ let inPaste = false;
207
+ let pasteAcc = "";
208
+ function feedKeys(data) {
209
+ let s = carry + data;
210
+ carry = "";
211
+ let i = 0;
212
+ while (i < s.length) {
213
+ if (inPaste) {
214
+ const end = s.indexOf(`${ESC}[201~`, i);
215
+ if (end === -1) {
216
+ // giữ lại tối đa 5 ký tự cuối phòng marker bị cắt ngang chunk
217
+ let safe = s.length;
218
+ for (let k = 1; k <= 5; k++) if (`${ESC}[201~`.startsWith(s.slice(s.length - k))) { safe = s.length - k; break; }
219
+ pasteAcc += s.slice(i, safe);
220
+ carry = s.slice(safe);
221
+ return;
222
+ }
223
+ pasteAcc += s.slice(i, end);
224
+ inPaste = false;
225
+ pushPaste(pasteAcc.replace(/\r\n/g, "\n").replace(/\r/g, "\n"));
226
+ pasteAcc = "";
227
+ i = end + 6;
228
+ draw();
229
+ continue;
230
+ }
231
+ const rest = s.slice(i);
232
+ if (`${ESC}[200~`.startsWith(rest) && rest.length < 6) { carry = rest; return; } // marker dở
233
+ if (rest.startsWith(`${ESC}[200~`)) { inPaste = true; pasteAcc = ""; i += 6; continue; }
234
+ const ch = s[i];
235
+ if (ch === ESC) {
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
+ }
249
+ const m = rest.match(/^\x1b\[[0-9;]*[A-Za-z~]/) || rest.match(/^\x1bO[A-Za-z]/);
250
+ if (m) { i += m[0].length; continue; }
251
+ if (rest.length < 3) { carry = rest; return; } // esc dở
252
+ i += 1;
253
+ continue;
254
+ }
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
257
+ submit();
258
+ if (ch === "\r" && s[i + 1] === "\n") i++; // nuốt \n của \r\n
259
+ i++;
260
+ continue;
261
+ }
262
+ if (ch === "\x7f" || ch === "\b") { backspace(); refreshMenu(); draw(); i++; continue; }
263
+ if (ch === "\x03") { onInterrupt?.(); i++; continue; } // Ctrl+C
264
+ if (ch === "\x15") { parts = []; refreshMenu(); draw(); i++; continue; } // Ctrl+U xoá dòng
265
+ if (ch === "\x04") { if (!fullText()) onEOF?.(); i++; continue; } // Ctrl+D
266
+ if (ch === "\t") { // Tab điền lệnh đang chọn + dấu cách (để gõ tham số), đóng menu
267
+ if (menu.length) { parts = [{ type: "text", value: menu[menuIdx].name + " " }]; menu = []; draw(); }
268
+ i++;
269
+ continue;
270
+ }
271
+ if (ch < " ") { i++; continue; } // control khác — bỏ
272
+ // ký tự in được (kể cả UTF-8 vì stdin đã setEncoding utf8)
273
+ pushText(ch);
274
+ refreshMenu();
275
+ draw();
276
+ i++;
277
+ }
278
+ }
279
+
280
+ function onData(data) {
281
+ // Heuristic fallback cho terminal KHÔNG hỗ trợ bracketed-paste: một lần gõ
282
+ // tay không bao giờ tạo ra "xuống dòng GIỮA chuỗi". Vậy nếu cả cục có một
283
+ // newline KHÔNG nằm cuối (tức còn nội dung sau nó) → đó là PASTE nhiều dòng.
284
+ if (!inPaste && !data.includes(`${ESC}[200~`)) {
285
+ const norm = data.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
286
+ const firstNl = norm.indexOf("\n");
287
+ if (firstNl !== -1 && firstNl < norm.length - 1 && data.length > 1) {
288
+ const body = norm.endsWith("\n") ? norm.slice(0, -1) : norm; // bỏ 1 newline đuôi
289
+ pushPaste(body); // ≥2 dòng → chip; KHÔNG auto-submit (người dùng Enter để gửi)
290
+ draw();
291
+ return;
292
+ }
293
+ }
294
+ feedKeys(data);
295
+ }
296
+
297
+ return {
298
+ tty: true,
299
+ start() {
300
+ process.stdin.setEncoding("utf8");
301
+ if (process.stdin.setRawMode) process.stdin.setRawMode(true);
302
+ process.stdin.resume();
303
+ w(`${ESC}[?2004h`); // bật bracketed paste
304
+ process.stdin.on("data", onData); // Shift+Tab (\x1b[Z) + mũi tên xử lý trong feedKeys
305
+ // vá stdout: mọi output → commit phía trên thanh
306
+ process.stdout.write = (chunk, enc, cb) => {
307
+ feedOutput(typeof chunk === "string" ? chunk : chunk.toString(typeof enc === "string" ? enc : "utf8"));
308
+ if (typeof enc === "function") enc();
309
+ else if (typeof cb === "function") cb();
310
+ return true;
311
+ };
312
+ frameTimer = setInterval(() => {
313
+ if (statusText && !liveOut) {
314
+ frame++;
315
+ draw();
316
+ }
317
+ }, 90);
318
+ draw();
319
+ },
320
+ print(s = "") {
321
+ feedOutput(s.endsWith("\n") ? s : s + "\n");
322
+ },
323
+ status(text) {
324
+ statusText = text || null;
325
+ draw();
326
+ },
327
+ setPrompt(label) {
328
+ promptLabel = label || "";
329
+ draw();
330
+ },
331
+ read(label) {
332
+ if (label != null) promptLabel = label;
333
+ if (queue.length) return Promise.resolve(queue.shift());
334
+ draw();
335
+ return new Promise((res) => (waiter = res));
336
+ },
337
+ close() {
338
+ try {
339
+ if (frameTimer) clearInterval(frameTimer);
340
+ process.stdout.write = realWrite;
341
+ w(`${ESC}[?2004l${ESC}[?25h\n`);
342
+ if (process.stdin.setRawMode) process.stdin.setRawMode(false);
343
+ process.stdin.pause();
344
+ } catch {}
345
+ },
346
+ };
347
+ }