@noobdemon/noob-cli 1.7.10 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noobdemon/noob-cli",
3
- "version": "1.7.10",
3
+ "version": "1.8.1",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/src/agent.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import os from "node:os";
2
2
  import { stream } from "./api.js";
3
- import { loadMemory } from "./memory.js";
3
+ import { loadMemory, memoryStats } from "./memory.js";
4
4
  import { listRoots } from "./tools.js";
5
5
  import { t } from "./i18n.js";
6
6
  import { countTokens } from "./tokens.js";
@@ -262,23 +262,72 @@ export function filesLedger(history) {
262
262
  // lại mỗi lượt. Không có file → nhắc model tự tạo khi rút ra điều đáng nhớ.
263
263
  // Framing MẠNH: phần `## Rules` là binding (luật dự án), `## Notes` mới là
264
264
  // tham khảo. Đặt ngay sau SYSTEM trong buildPrompt() để không bị lost-in-the-middle.
265
+ // Footer "📊 noob.md hiện tại: …" cho model thấy memory "tươi" hay "stale" — nếu
266
+ // đã cũ, model có thể tự quyết định /learn hoặc xác minh lại với filesystem.
265
267
  function memoryBlock() {
266
268
  const mem = loadMemory();
269
+ const stats = memoryStats();
270
+ const statLine = stats
271
+ ? `\n\n📊 noob.md hiện tại: ${stats.lines} dòng (${stats.rules} rules, ${stats.notes} notes) · cập nhật ${relTime(stats.mtime)}. Nếu cũ/quá ít, cân nhắc /learn để chưng cất bài học mới.`
272
+ : "";
267
273
  if (!mem)
268
- return "# PROJECT RULES & 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 đó.)";
274
+ return "# PROJECT RULES & 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 đó.)" + statLine;
269
275
  return (
270
276
  "# PROJECT RULES & MEMORY (noob.md) — BINDING\n" +
271
277
  "Phần `## Rules` dưới đây là LUẬT DỰ ÁN bạn PHẢI tuân theo trong mọi hành động ở lượt này — coi như mở rộng của SYSTEM, không phải gợi ý. Phần `## Notes` là quan sát tham khảo, có thể xác minh lại với filesystem nếu nghi ngờ.\n\n" +
272
278
  mem +
273
- "\n\nTrước khi emit hành động, đối chiếu với `## Rules` ở trên. Học thêm điều đáng nhớ → cập nhật noob.md bằng edit_file/write_file (Notes mới, promote lên Rules khi đã chứng minh)."
279
+ "\n\nTrước khi emit hành động, đối chiếu với `## Rules` ở trên. Học thêm điều đáng nhớ → cập nhật noob.md bằng edit_file/write_file (Notes mới, promote lên Rules khi đã chứng minh)." +
280
+ statLine
274
281
  );
275
282
  }
276
283
 
284
+ // Recent sessions block: danh sách phiên gần đây CÙNG workspace. Model thấy
285
+ // breadcrumbs để (a) trả lời "phiên trước làm gì" mà KHÔNG cần /resume, (b) gợi
286
+ // ý /resume nếu user muốn tiếp tục. Bỏ qua phiên hiện tại (repl.js lọc).
287
+ // recentSessions: [{ id, title, turns, updatedAt }] — đã sort mới → cũ.
288
+ export function recentSessionsBlock(recentSessions) {
289
+ if (!recentSessions || !recentSessions.length) return "";
290
+ const lines = [
291
+ "# RECENT SESSIONS IN THIS WORKSPACE (newest first)",
292
+ "Đây là các phiên TRƯỚC của cùng dự án. User có thể `/resume <id>` để tiếp tục 1 phiên cụ thể (xem lịch sử đầy đủ). Nếu user nói chung chung ('tiếp tục hôm qua', 'làm lại cái kia') mà KHÔNG chỉ rõ — hỏi lại hoặc dùng breadcrumbs dưới để đoán (xem title + số lượt + thời gian).",
293
+ "",
294
+ ];
295
+ for (const s of recentSessions) {
296
+ const ago = relTime(s.updatedAt);
297
+ const title = s.title || "(chưa đặt tiêu đề)";
298
+ lines.push(`- \`${s.id}\` — "${title}" · ${s.turns} lượt · ${ago}`);
299
+ }
300
+ return lines.join("\n");
301
+ }
302
+
303
+ // "X ago" ngắn gọn, tiếng Việt. Dùng cho noob.md mtime + recent sessions.
304
+ export function relTime(ts) {
305
+ if (!ts) return "—";
306
+ const ms = Date.now() - ts;
307
+ if (ms < 5000) return "vừa xong"; // < 5s hoặc tương lai → "vừa xong" (tránh "0s trước" xấu)
308
+ const s = Math.floor(ms / 1000);
309
+ if (s < 60) return `${s}s trước`;
310
+ const m = Math.floor(s / 60);
311
+ if (m < 60) return `${m} phút trước`;
312
+ const h = Math.floor(m / 60);
313
+ if (h < 24) return `${h} giờ trước`;
314
+ const d = Math.floor(h / 24);
315
+ if (d < 30) return `${d} ngày trước`;
316
+ const mo = Math.floor(d / 30);
317
+ if (mo < 12) return `${mo} tháng trước`;
318
+ return `${Math.floor(d / 365)} năm trước`;
319
+ }
320
+
277
321
  // The proxy is stateless, so we serialize the whole transcript into one prompt.
278
322
  // extraToolsDoc: chuỗi mô tả thêm tool (vd spawn_agent khi agent mode bật) được
279
323
  // chèn ngay sau SYSTEM để model biết và dùng được.
280
- function buildSystem(history, extraToolsDoc, goal) {
324
+ // recentSessions: breadcrumbs các phiên trước cùng workspace (repl.js cung cấp)
325
+ // → chèn ngay sau memoryBlock() để model "thấy" lịch sử dù chưa /resume.
326
+ function buildSystem(history, extraToolsDoc, goal, recentSessions) {
281
327
  const parts = [SYSTEM, "", memoryBlock()];
328
+ if (recentSessions && recentSessions.length) {
329
+ parts.push("", recentSessionsBlock(recentSessions));
330
+ }
282
331
  if (goal && goal.trim()) parts.push("", goalBlock(goal));
283
332
  if (extraToolsDoc) parts.push("", extraToolsDoc);
284
333
  parts.push("", runtimeContext());
@@ -354,7 +403,7 @@ function extractJsonObject(s, from) {
354
403
  * @param {(msg:string)=>void} opts.onStatus thinking/streaming status
355
404
  * @returns {Promise<string>} the final assistant answer (no tool block)
356
405
  */
357
- export async function runAgent({ history, model, signal, onTool, onStatus, onDelta, onSteer, tokenMeter, extraToolsDoc, goal }) {
406
+ export async function runAgent({ history, model, signal, onTool, onStatus, onDelta, onSteer, tokenMeter, extraToolsDoc, goal, recentSessions }) {
358
407
  // [GỠ BUDGET 2026-06-06] Không còn token budget enforcement. Agent/loop/sub-agent
359
408
  // chạy không giới hạn token. Dừng theo: GOAL đạt, <<LOOP_DONE>>, <<ULTRA_DONE>>,
360
409
  // model tự kết thúc reply không có tool block, hoặc user Ctrl+C.
@@ -370,7 +419,7 @@ export async function runAgent({ history, model, signal, onTool, onStatus, onDel
370
419
  // Bộ nhớ dài hạn: thử tóm tắt nếu history đã phình. Im lặng nếu không cần.
371
420
  try { await maybeSummarize(history, { model, signal }); } catch {}
372
421
 
373
- const system = buildSystem(history, extraToolsDoc, goal);
422
+ const system = buildSystem(history, extraToolsDoc, goal, recentSessions);
374
423
  const message = buildUserMessage(history);
375
424
  tokenMeter?.addInput(countTokens(message));
376
425
  onStatus?.("thinking");
package/src/i18n.js CHANGED
@@ -130,6 +130,9 @@ export const t = {
130
130
  loopAutoStop: (n) => `Loop tự dừng sau tick #${n} — model phát <<LOOP_DONE>> (task hoàn tất).`,
131
131
  loopAlreadyRunning: "Đã có loop đang chạy. /loop stop trước khi đặt loop mới.",
132
132
  learning: "đang chưng cất bài học vào noob.md…",
133
+ learnSuggest: (n) => `💡 Phiên này có ${n} lượt. Gõ /learn trước để chưng cất bài học vào noob.md (sau khi /new thì history sẽ mất).`,
134
+ memoryStatus: (lines, rules, notes, ago) => `📝 noob.md: ${lines} dòng (${rules} rules, ${notes} notes) · cập nhật ${ago}`,
135
+ memoryMissing: "📝 noob.md: chưa có — gõ /init để tạo từ dự án.",
133
136
  compactRunning: "đang tóm tắt phiên để gọn ngữ cảnh…",
134
137
  compactEmpty: "Phiên còn trống — không có gì để tóm tắt.",
135
138
  compactSkipped: "Phiên còn ngắn hoặc tóm tắt thất bại — bỏ qua.",
package/src/memory.js CHANGED
@@ -19,3 +19,32 @@ export function loadMemory() {
19
19
  return null;
20
20
  }
21
21
  }
22
+
23
+ // Thống kê nhanh về noob.md: dùng cho (a) footer trong system prompt để model
24
+ // biết memory "tươi" hay "stale" và (b) banner khi mở noob để user thấy model
25
+ // có memory gì. Trả về null nếu chưa có file.
26
+ export function memoryStats() {
27
+ let txt;
28
+ try {
29
+ txt = fs.readFileSync(memoryPath(), "utf8");
30
+ } catch {
31
+ return null;
32
+ }
33
+ if (!txt.trim()) return null;
34
+ const lines = txt.split("\n");
35
+ let rules = 0;
36
+ let notes = 0;
37
+ let inSection = "";
38
+ for (const l of lines) {
39
+ if (/^##\s+Rules\b/i.test(l)) { inSection = "rules"; continue; }
40
+ if (/^##\s+Notes\b/i.test(l)) { inSection = "notes"; continue; }
41
+ if (/^##\s/.test(l)) { inSection = ""; continue; } // mục khác → reset
42
+ if (/^\s*[-*]\s+/.test(l)) {
43
+ if (inSection === "rules") rules++;
44
+ else if (inSection === "notes") notes++;
45
+ }
46
+ }
47
+ let mtime = 0;
48
+ try { mtime = fs.statSync(memoryPath()).mtimeMs; } catch {}
49
+ return { lines: lines.length, rules, notes, mtime, path: memoryPath() };
50
+ }
package/src/repl.js CHANGED
@@ -11,7 +11,7 @@ import { runTool, describe, DESTRUCTIVE, addRoot, listRoots } from "./tools.js";
11
11
  import { MODELS, PROVIDERS, findModel, providerColor, DEFAULT_MODEL } from "./models.js";
12
12
  import { c, banner, modelBadge, renderMarkdown, box } from "./ui.js";
13
13
  import { config } from "./config.js";
14
- import { loadMemory, memoryPath } from "./memory.js";
14
+ import { loadMemory, memoryPath, memoryStats } from "./memory.js";
15
15
  import { t } from "./i18n.js";
16
16
  import { checkLatest, runUpdate, CURRENT } from "./update.js";
17
17
  import * as sessions from "./sessions.js";
@@ -872,6 +872,17 @@ NGUYÊN TẮC:
872
872
  tui.start();
873
873
  banner();
874
874
  printStatus(state);
875
+ // noob.md status line — cho user thấy model có memory gì (số dòng, rules/notes,
876
+ // bao lâu cập nhật). Nếu chưa có → gợi ý /init. Giúp user tin tưởng model có
877
+ // "trí nhớ" thay vì cảm giác mù context khi bắt đầu phiên mới.
878
+ {
879
+ const stats = memoryStats();
880
+ if (stats) {
881
+ console.log(c.dim(` 📝 noob.md: ${stats.lines} dòng (${stats.rules} rules, ${stats.notes} notes) · cập nhật ${relTime(stats.mtime)}`));
882
+ } else {
883
+ console.log(c.dim(` 📝 noob.md: chưa có — gõ /init để tạo từ dự án.`));
884
+ }
885
+ }
875
886
  if (!config.apiKey) console.log("\n" + c.tool(" " + t.notLoggedIn) + "\n");
876
887
  else console.log(c.dim(" " + t.ready + "\n"));
877
888
 
@@ -1076,6 +1087,10 @@ NGUYÊN TẮC:
1076
1087
  signal: abort.signal,
1077
1088
  tokenMeter,
1078
1089
  goal: state.goal,
1090
+ // Breadcrumbs: 5 phiên gần nhất CÙNG workspace, trừ phiên hiện tại.
1091
+ // Model thấy "đã làm gì" trước đó dù chưa /resume — sửa cảm giác
1092
+ // "model không nhớ session" khi user mở phiên mới trong cùng dự án.
1093
+ recentSessions: sessions.list(5, process.cwd()).filter((s) => s.id !== session?.id),
1079
1094
  extraToolsDoc: state.agentMode ? spawnAgentToolsDoc(0) : "",
1080
1095
  onStatus: () => tick(t.thinking),
1081
1096
  onSteer: () => {
@@ -1344,6 +1359,15 @@ NGUYÊN TẮC:
1344
1359
  break;
1345
1360
  case "clear":
1346
1361
  case "new":
1362
+ // Nếu phiên hiện tại có nhiều lượt (≥ 5 user turns) → nhắc /learn TRƯỚC
1363
+ // khi xoá, vì sau khi clear thì history mất và /learn sẽ chạy trên
1364
+ // history rỗng. In hint để user tự quyết định; không block (UX).
1365
+ {
1366
+ const userTurns = state.history.filter((m) => m.role === "user").length;
1367
+ if (userTurns >= 5) {
1368
+ console.log(c.dim(` 💡 Phiên này có ${userTurns} lượt. Gõ /learn trước để chưng cất bài học vào noob.md (sau khi /new thì history sẽ mất).`));
1369
+ }
1370
+ }
1347
1371
  persist(); // giữ lại phiên cũ trên đĩa
1348
1372
  state.history = [];
1349
1373
  state._longSessionWarned = false; // reset cờ cảnh báo phiên dài
@@ -1351,6 +1375,13 @@ NGUYÊN TẮC:
1351
1375
  if (!tui.tty) console.clear();
1352
1376
  banner();
1353
1377
  printStatus(state);
1378
+ // noob.md status cũng hiện ở banner phiên mới (gọi lại sau clear)
1379
+ {
1380
+ const stats = memoryStats();
1381
+ if (stats) {
1382
+ console.log(c.dim(` 📝 noob.md: ${stats.lines} dòng (${stats.rules} rules, ${stats.notes} notes) · cập nhật ${relTime(stats.mtime)}`));
1383
+ }
1384
+ }
1354
1385
  console.log(c.dim(" " + t.ctxCleared + "\n"));
1355
1386
  break;
1356
1387
  case "resume":
package/src/tui.js CHANGED
@@ -12,6 +12,58 @@ import { c } from "./ui.js";
12
12
  const ESC = "\x1b";
13
13
  const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
14
14
  const visLen = (s) => s.replace(ANSI_RE, "").length;
15
+ // Trả index trong `text` mà tại đó vị trí VISUAL đạt `targetVis`. Bỏ qua toàn
16
+ // bộ ANSI escape sequence khi đếm. Dùng cho soft-wrap khi text có màu.
17
+ function findVisPos(text, targetVis) {
18
+ let vis = 0;
19
+ let i = 0;
20
+ while (i < text.length && vis < targetVis) {
21
+ if (text[i] === "\x1b") {
22
+ const m = text.slice(i).match(/^\x1b\[[0-9;?]*[ -/]*[@-~]/);
23
+ if (m) { i += m[0].length; continue; }
24
+ }
25
+ vis++;
26
+ i++;
27
+ }
28
+ return i;
29
+ }
30
+ // Soft-wrap `text` thành tối đa `maxLines` dòng, sao cho mỗi dòng có độ rộng
31
+ // VISUAL ≤ `width`. Ưu tiên cắt tại khoảng trắng gần cuối (word boundary); nếu
32
+ // không có space hợp lý → hard-slice theo visual position. Nếu text gốc có
33
+ // ANSI escape thì MỌI dòng output (kể cả dòng cuối "vừa khít") đều kết thúc
34
+ // bằng `\x1b[0m` reset — chống "chảy máu" màu khi status bar có dim/accent.
35
+ // Nếu vẫn còn dư → dòng cuối thêm "…".
36
+ function wrapText(text, width, maxLines) {
37
+ if (!text) return [""];
38
+ const hasAnsi = /\x1b/.test(text);
39
+ const RESET = "\x1b[0m";
40
+ const close = (line) => (hasAnsi ? line + RESET : line);
41
+ if (visLen(text) <= width) return [close(text)];
42
+ const lines = [];
43
+ let remaining = text;
44
+ while (remaining && lines.length < maxLines) {
45
+ if (visLen(remaining) <= width) {
46
+ lines.push(close(remaining));
47
+ remaining = "";
48
+ break;
49
+ }
50
+ // Cắt tại vị trí visual = width. Sau đó thử lùi về space gần nhất (trong
51
+ // khoảng 30–100% width) để tránh cắt giữa từ.
52
+ let cutPos = findVisPos(remaining, width);
53
+ const slice = remaining.slice(0, cutPos);
54
+ const lastSpace = slice.lastIndexOf(" ");
55
+ if (lastSpace > width * 0.3) cutPos = lastSpace;
56
+ lines.push(close(remaining.slice(0, cutPos).trimEnd()));
57
+ remaining = remaining.slice(cutPos).trimStart();
58
+ }
59
+ if (remaining && lines.length) {
60
+ const last = lines.length - 1;
61
+ const lastLine = lines[last];
62
+ const body = lastLine.endsWith(RESET) ? lastLine.slice(0, -RESET.length) : lastLine;
63
+ lines[last] = (body.length ? body.slice(0, -1) : "") + "…" + (hasAnsi ? RESET : "");
64
+ }
65
+ return lines;
66
+ }
15
67
  const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
16
68
 
17
69
  export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer } = {}) {
@@ -126,39 +178,111 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
126
178
  menu = [];
127
179
  }
128
180
 
129
- // ----- ô nhập → chuỗi đầy đủ + bản tô màu (paste = chip) -----
181
+ // ----- ô nhập → chuỗi đầy đủ + bản tô màu (paste = chip có preview) -----
182
+ // Chip paste: hiện `[pasted N lines: "dòng đầu tiên…"]` để user xem được
183
+ // nội dung trước khi Enter. N dòng thì lấy dòng 1; nếu dòng 1 dài >24 ký tự
184
+ // thì cắt + "…". Tổng chiều dài chip ~38 ký tự — đủ gọn để 1–2 chip vẫn
185
+ // wrap được trong thanh input tiêu chuẩn.
186
+ const PASTE_PREVIEW_MAX = 24;
187
+ const pastePreview = (content) => {
188
+ const firstLine = (content.split("\n")[0] || "").trim();
189
+ if (!firstLine) return "";
190
+ return firstLine.length > PASTE_PREVIEW_MAX
191
+ ? firstLine.slice(0, PASTE_PREVIEW_MAX - 1) + "…"
192
+ : firstLine;
193
+ };
130
194
  const cellStr = (x) => (x.paste !== undefined ? x.paste : x.c);
131
- const cellPlain = (x) => (x.paste !== undefined ? `[pasted ${x.lines} lines]` : x.c);
195
+ const cellPlain = (x) => {
196
+ if (x.paste === undefined) return x.c;
197
+ const preview = pastePreview(x.paste);
198
+ return preview ? `[pasted ${x.lines} lines: "${preview}"]` : `[pasted ${x.lines} lines]`;
199
+ };
132
200
  const fullText = () => cells.map(cellStr).join("");
133
201
  const coloredInput = () =>
134
- cells.map((x) => (x.paste !== undefined ? c.dim(`[pasted ${x.lines} lines]`) : x.c)).join("");
202
+ cells.map((x) => {
203
+ if (x.paste === undefined) return x.c;
204
+ const preview = pastePreview(x.paste);
205
+ const label = preview ? `[pasted ${x.lines} lines: "${preview}"]` : `[pasted ${x.lines} lines]`;
206
+ return c.dim(label);
207
+ }).join("");
135
208
 
136
- // Dựng thanh nhập + tính cột con trỏ trên màn (`cursorScreenCol`). Vừa khung →
137
- // màu đầy đủ. Tràn khung → cuộn ngang theo plain sao cho con trỏ luôn trong
138
- // tầm nhìn, chèn "…" đầu/cuối bị cắt.
209
+ // Dựng thanh nhập + tính vị trí con trỏ trên màn (`cursorScreenCol` +
210
+ // `cursorScreenRow`). Vừa khung → màu đầy đủ, 1 dòng. Tràn khung soft-
211
+ // wrap dọc (≤ MAX_BAR_LINES), indent các dòng nối tiếp dưới prompt để con trỏ
212
+ // vẫn map đúng (col = promptW + colInLine). Dòng cuối nếu vẫn bị truncate →
213
+ // thêm "…".
139
214
  let cursorScreenCol = 0;
215
+ let cursorScreenRow = 0;
216
+ let barRows = 1; // số dòng bar hiện tại; cập nhật bởi renderBar. placeCursor
217
+ // dùng để tính upBy — KHÔNG dùng totalRows (gồm cả top/menu
218
+ // rows phía trên) vì sẽ kéo cursor lên quá cao, lần commit
219
+ // kế tiếp ESC[J xóa luôn spinner+bar+dòng response trên cùng.
140
220
  function renderBar() {
141
221
  const promptW = visLen(promptLabel);
142
222
  const budget = Math.max(4, cols() - promptW - 1);
223
+ const indent = " ".repeat(promptW);
143
224
  const plains = cells.map(cellPlain);
144
- let curCol = 0;
145
- for (let k = 0; k < cur; k++) curCol += plains[k].length;
225
+ const plain = plains.join("");
146
226
  let total = 0;
147
227
  for (const p of plains) total += p.length;
228
+ let curCharPos = 0;
229
+ for (let k = 0; k < cur; k++) curCharPos += plains[k].length;
230
+
231
+ // Vừa khung → 1 dòng như cũ, con trỏ map thẳng.
148
232
  if (total <= budget) {
149
- cursorScreenCol = promptW + curCol;
233
+ barRows = 1;
234
+ cursorScreenRow = 0;
235
+ cursorScreenCol = promptW + curCharPos;
150
236
  return promptLabel + coloredInput();
151
237
  }
152
- const plain = plains.join("");
153
- let start = curCol > budget - 1 ? curCol - (budget - 1) : 0;
154
- if (start > total - budget) start = total - budget;
155
- if (start < 0) start = 0;
156
- const end = Math.min(total, start + budget);
157
- const arr = [...plain.slice(start, end)];
158
- if (start > 0) arr[0] = "…";
159
- if (end < total) arr[arr.length - 1] = "…";
160
- cursorScreenCol = Math.min(promptW + budget, Math.max(promptW, promptW + (curCol - start)));
161
- return promptLabel + arr.join("");
238
+
239
+ // Tràn khung soft-wrap dọc tối đa MAX_BAR_LINES, ưu tiên cắt tại space.
240
+ // Dòng 0 promptLabel; các dòng sau thụt vào `indent` để con trỏ thẳng
241
+ // cột với text sau prompt. Paste chip đếm như 1 "từ" dài `[pasted N lines]`
242
+ // nếu không vừa, đẩy nguyên chip sang dòng mới (vẫn đọc được).
243
+ const MAX_BAR_LINES = 5;
244
+ const lines = [];
245
+ let pos = 0;
246
+ while (pos < total && lines.length < MAX_BAR_LINES) {
247
+ const remaining = total - pos;
248
+ if (remaining <= budget) {
249
+ lines.push(plain.slice(pos));
250
+ break;
251
+ }
252
+ // Tìm space gần nhất trong slice [pos, pos+budget]; nếu > 30% budget → cắt
253
+ // tại đó. Không có space hợp lý → hard-slice theo budget.
254
+ const slice = plain.slice(pos, pos + budget);
255
+ const lastSpace = slice.lastIndexOf(" ");
256
+ let cutLen = budget;
257
+ if (lastSpace > budget * 0.3) cutLen = lastSpace;
258
+ lines.push(slice.slice(0, cutLen).trimEnd());
259
+ pos += cutLen;
260
+ while (pos < total && plain[pos] === " ") pos++; // bỏ space đầu dòng sau
261
+ }
262
+ // Nếu vẫn còn dư → đánh dấu dòng cuối bị truncate.
263
+ if (pos < total && lines.length) {
264
+ const last = lines.length - 1;
265
+ const lastLine = lines[last];
266
+ lines[last] = (lastLine.length >= budget ? lastLine.slice(0, -1) : lastLine) + "…";
267
+ }
268
+
269
+ // Map curCharPos → (line, colInLine).
270
+ let curLine = 0;
271
+ let acc = 0;
272
+ for (let i = 0; i < lines.length; i++) {
273
+ const lineLen = lines[i].length;
274
+ if (curCharPos <= acc + lineLen || i === lines.length - 1) {
275
+ curLine = i;
276
+ break;
277
+ }
278
+ acc += lineLen;
279
+ }
280
+ const colInLine = Math.min(curCharPos - acc, lines[curLine].length);
281
+ cursorScreenRow = curLine;
282
+ cursorScreenCol = promptW + colInLine;
283
+ barRows = lines.length;
284
+
285
+ return lines.map((l, i) => (i === 0 ? promptLabel : indent) + l).join("\n");
162
286
  }
163
287
  function topRow() {
164
288
  if (liveOut) {
@@ -166,17 +290,17 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
166
290
  // CHỈ KHI liveOut đủ ngắn để cả dòng (liveOut + meta) chắc chắn fit trong
167
291
  // 1 dòng terminal. Nếu liveOut quá dài → ưu tiên prose, bỏ meta lượt này
168
292
  // (tránh terminal wrap làm dòng tạm kẹt lại trong prose vĩnh viễn — xem
169
- // Note "token meter chèn vào prose" trong noob.md).
293
+ // Note "token meter chèn vào prose" trong noob.md). Trường hợp wrap nhiều
294
+ // dòng: chỉ dòng đầu ghép meta (nếu vừa), các dòng sau prose thuần.
170
295
  if (busy && busyMeta) {
171
296
  const meta = c.dim(" · " + busyMeta);
172
297
  const metaLen = visLen(meta);
173
- const liveLen = visLen(liveOut);
174
- if (liveLen + metaLen <= cols()) {
175
- return liveOut + meta;
298
+ if (visLen(liveOut) + metaLen <= cols()) {
299
+ return [liveOut + meta];
176
300
  }
177
- // liveOut đã dài: hiện prose nguyên trạng, bỏ meta để tránh wrap.
301
+ // liveOut đã dài: meta sẽ bị bỏ (xem note trên).
178
302
  }
179
- return liveOut.length > cols() ? liveOut.slice(0, cols()) : liveOut;
303
+ return wrapText(liveOut, cols(), 2);
180
304
  }
181
305
  const spin = FRAMES[frame % FRAMES.length];
182
306
  // Khi busy, LUÔN hiện elapsed+tokens (busyMeta) cạnh statusText/busyLabel để
@@ -185,12 +309,12 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
185
309
  const meta = busy && busyMeta ? c.dim(" · " + busyMeta) : "";
186
310
  const tail = busy ? c.dim(" · Ctrl+C để dừng") : "";
187
311
  const line = c.dim(spin + " ") + statusText + meta + tail;
188
- return line.length > cols() ? line.slice(0, cols()) : line;
312
+ return wrapText(line, cols(), 2);
189
313
  }
190
314
  if (busy) {
191
315
  const meta = busyMeta ? " · " + busyMeta : "";
192
316
  const line = c.dim(spin + " " + (busyLabel || "đang chạy") + meta + " · Ctrl+C để dừng");
193
- return line.length > cols() ? line.slice(0, cols()) : line;
317
+ return wrapText(line, cols(), 2);
194
318
  }
195
319
  return null;
196
320
  }
@@ -210,20 +334,30 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
210
334
  function rows() {
211
335
  const r = [];
212
336
  const top = topRow();
213
- if (top !== null) r.push(top);
337
+ if (top !== null) r.push(...top);
214
338
  for (const mr of menuRows()) r.push(mr);
215
- r.push(renderBar());
339
+ const bar = renderBar();
340
+ if (bar) r.push(...bar.split("\n"));
216
341
  return r;
217
342
  }
218
343
  function eraseSeq() {
219
344
  if (!drawn) return "\r";
220
345
  return "\r" + (prevRows > 1 ? `${ESC}[${prevRows - 1}A` : "") + `${ESC}[J`;
221
346
  }
222
- // Sau khi vẽ xong các hàng, con trỏ đang ở CUỐI thanh (hàng cuối). Đưa nó về
223
- // đúng cột con trỏ logic: \r về cột 0 rồi dịch phải `cursorScreenCol`.
224
- const placeCursor = () => "\r" + (cursorScreenCol > 0 ? `${ESC}[${cursorScreenCol}C` : "");
347
+ // Sau khi vẽ xong các hàng, con trỏ đang ở CUỐI thanh (hàng cuối, cột cuối).
348
+ // Đưa về đúng (row, col) của con trỏ logic: \r về cột 0 đi lên `upBy` hàng
349
+ // (nếu thanh wrap nhiều dòng) dịch phải `cursorScreenCol` cột.
350
+ // upBy dùng `barRows` (số dòng BAR, set bởi renderBar) — KHÔNG dùng totalRows
351
+ // (gồm cả top/menu rows phía trên bar) vì sẽ kéo cursor lên quá cao.
352
+ const placeCursor = () => {
353
+ const upBy = barRows - 1 - cursorScreenRow;
354
+ let s = "\r";
355
+ if (upBy > 0) s += `${ESC}[${upBy}A`;
356
+ if (cursorScreenCol > 0) s += `${ESC}[${cursorScreenCol}C`;
357
+ return s;
358
+ };
225
359
  function draw() {
226
- const rs = rows(); // rows() → renderBar() cập nhật cursorScreenCol
360
+ const rs = rows(); // rows() → renderBar() cập nhật cursorScreenRow/Col + barRows
227
361
  w(`${ESC}[?25l` + eraseSeq() + rs.join("\n") + placeCursor() + `${ESC}[?25h`);
228
362
  prevRows = rs.length;
229
363
  drawn = true;