@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 +1 -1
- package/src/agent.js +55 -6
- package/src/i18n.js +3 -0
- package/src/memory.js +29 -0
- package/src/repl.js +32 -1
- package/src/tui.js +167 -33
package/package.json
CHANGED
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
|
-
|
|
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) =>
|
|
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) =>
|
|
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
|
|
137
|
-
//
|
|
138
|
-
//
|
|
209
|
+
// Dựng thanh nhập + tính vị trí con trỏ trên màn (`cursorScreenCol` +
|
|
210
|
+
// `cursorScreenRow`). Vừa khung → tô 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
|
-
|
|
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
|
-
|
|
233
|
+
barRows = 1;
|
|
234
|
+
cursorScreenRow = 0;
|
|
235
|
+
cursorScreenCol = promptW + curCharPos;
|
|
150
236
|
return promptLabel + coloredInput();
|
|
151
237
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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 có 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
|
-
|
|
174
|
-
|
|
175
|
-
return liveOut + meta;
|
|
298
|
+
if (visLen(liveOut) + metaLen <= cols()) {
|
|
299
|
+
return [liveOut + meta];
|
|
176
300
|
}
|
|
177
|
-
// liveOut đã dài:
|
|
301
|
+
// liveOut đã dài: meta sẽ bị bỏ (xem note ở trên).
|
|
178
302
|
}
|
|
179
|
-
return 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
|
|
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
|
|
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
|
-
|
|
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).
|
|
223
|
-
// đúng
|
|
224
|
-
|
|
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
|
|
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;
|