@noobdemon/noob-cli 1.7.8 → 1.8.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noobdemon/noob-cli",
3
- "version": "1.7.8",
3
+ "version": "1.8.0",
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,45 +262,88 @@ 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 buildPrompt(history, extraToolsDoc, goal) {
281
- const msgs = compact(history, MAX_PROMPT_CHARS);
282
- // Thứ tự CÓ CHỦ ĐÍCH: SYSTEM → memoryBlock (Rules dự án, vị trí mạnh thứ 2,
283
- // tránh lost-in-the-middle) → goalBlock (hard completion requirement, attention
284
- // cao — chống agentic laziness + goal drift) → extraToolsDoc → runtimeContext
285
- // → filesLedger → CONVERSATION. noob.md (đặc biệt phần `## Rules`) phải nằm
286
- // sát SYSTEM để model coi là luật.
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) {
287
327
  const parts = [SYSTEM, "", memoryBlock()];
288
- if (goal && goal.trim()) {
289
- parts.push("", goalBlock(goal));
328
+ if (recentSessions && recentSessions.length) {
329
+ parts.push("", recentSessionsBlock(recentSessions));
290
330
  }
331
+ if (goal && goal.trim()) parts.push("", goalBlock(goal));
291
332
  if (extraToolsDoc) parts.push("", extraToolsDoc);
292
- parts.push("", runtimeContext(), "", filesLedger(history), "", "=".repeat(60), "# CONVERSATION", "");
333
+ parts.push("", runtimeContext());
334
+ return parts.join("\n");
335
+ }
336
+
337
+ function buildUserMessage(history) {
338
+ const msgs = compact(history, MAX_PROMPT_CHARS);
339
+ const parts = [filesLedger(history), "", "=".repeat(60), "# CONVERSATION", ""];
293
340
  for (const m of msgs) {
294
341
  if (m.role === "user") parts.push(`## USER\n${m.content}`);
295
342
  else if (m.role === "assistant") parts.push(`## ASSISTANT\n${m.content}`);
296
343
  else if (m.role === "tool") parts.push(`## TOOL RESULT (${m.name})\n${m.content}`);
297
344
  parts.push("");
298
345
  }
299
- parts.push("=".repeat(60));
300
- // Recency bias: câu chốt cuối prompt nằm ở vị trí attention mạnh nhất. Nhắc
301
- // model đối chiếu FILES CHANGED trước khi claim đã sửa file — chống ảo giác
302
- // "đã tạo file" khi chưa gọi write_file/edit_file.
303
- parts.push("Continue. Emit a tool block to act, or reply in Markdown if done. Before claiming any file was created/edited, verify it appears in the FILES CHANGED list above — if not, emit the tool call now.");
346
+ parts.push("=".repeat(60), "Continue. Emit a tool block to act, or reply in Markdown if done. Before claiming any file was created/edited, verify it appears in the FILES CHANGED list above — if not, emit the tool call now.");
304
347
  return parts.join("\n");
305
348
  }
306
349
 
@@ -360,7 +403,7 @@ function extractJsonObject(s, from) {
360
403
  * @param {(msg:string)=>void} opts.onStatus thinking/streaming status
361
404
  * @returns {Promise<string>} the final assistant answer (no tool block)
362
405
  */
363
- 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 }) {
364
407
  // [GỠ BUDGET 2026-06-06] Không còn token budget enforcement. Agent/loop/sub-agent
365
408
  // chạy không giới hạn token. Dừng theo: GOAL đạt, <<LOOP_DONE>>, <<ULTRA_DONE>>,
366
409
  // model tự kết thúc reply không có tool block, hoặc user Ctrl+C.
@@ -376,8 +419,9 @@ export async function runAgent({ history, model, signal, onTool, onStatus, onDel
376
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.
377
420
  try { await maybeSummarize(history, { model, signal }); } catch {}
378
421
 
379
- const prompt = buildPrompt(history, extraToolsDoc, goal);
380
- tokenMeter?.addInput(countTokens(prompt));
422
+ const system = buildSystem(history, extraToolsDoc, goal, recentSessions);
423
+ const message = buildUserMessage(history);
424
+ tokenMeter?.addInput(countTokens(message));
381
425
  onStatus?.("thinking");
382
426
  onDelta?.({ type: "step-start" });
383
427
  // Stream + auto-retry: bao lớp resilience cho lỗi stream cut / empty / network.
@@ -385,7 +429,7 @@ export async function runAgent({ history, model, signal, onTool, onStatus, onDel
385
429
  // trường hợp api.js trả về với finishReason bất thường (tool_unclosed/empty) hoặc
386
430
  // throw ApiError retryable (network drop, 5xx, timeout).
387
431
  const { text, finishReason } = await streamWithRetry({
388
- model, message: prompt, signal, tokenMeter, onDelta, onStatus,
432
+ model, message, system, signal, tokenMeter, onDelta, onStatus,
389
433
  });
390
434
  tokenMeter?.endOutput();
391
435
  onDelta?.({ type: "step-end" });
@@ -426,7 +470,7 @@ export async function runAgent({ history, model, signal, onTool, onStatus, onDel
426
470
  * backoff (1s, 2s, 4s, 8s, max 30s), tối đa 8 lần thử trước khi bỏ cuộc.
427
471
  * - Throw lại nếu signal abort hoặc lỗi không retryable.
428
472
  */
429
- async function streamWithRetry({ model, message, signal, tokenMeter, onDelta, onStatus }) {
473
+ async function streamWithRetry({ model, message, system, signal, tokenMeter, onDelta, onStatus }) {
430
474
  const MAX_RETRIES = 8;
431
475
  let lastErr = null;
432
476
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
@@ -435,6 +479,7 @@ async function streamWithRetry({ model, message, signal, tokenMeter, onDelta, on
435
479
  mode: "chat",
436
480
  model,
437
481
  message,
482
+ system,
438
483
  signal,
439
484
  onDelta: (d) => {
440
485
  tokenMeter?.pushOutputDelta(d);
package/src/api.js CHANGED
@@ -3,6 +3,33 @@
3
3
  // upstream. The CLI only ever sees the gateway URL + the user's key.
4
4
  import { config } from "./config.js";
5
5
 
6
+ // ── memoryToken: per-session random token for upstream conversation state.
7
+ // Browser sends something like "uuid_uuid" (two v4 UUIDs joined by _).
8
+ // Upstream requires both `remember: true` and a valid `memoryToken`.
9
+ // ────────────────────────────────────────────────────────────────────────────
10
+ let _sessionMemoryToken = null;
11
+
12
+ function makeUUID() {
13
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
14
+ const r = (Math.random() * 16) | 0;
15
+ const v = c === "x" ? r : (r & 0x3) | 0x8;
16
+ return v.toString(16);
17
+ });
18
+ }
19
+
20
+ function makeMemoryToken() {
21
+ return `${makeUUID()}_${makeUUID()}`;
22
+ }
23
+
24
+ function getMemoryToken() {
25
+ if (!_sessionMemoryToken) _sessionMemoryToken = makeMemoryToken();
26
+ return _sessionMemoryToken;
27
+ }
28
+
29
+ export function resetMemoryToken() {
30
+ _sessionMemoryToken = null;
31
+ }
32
+
6
33
  // Opt-in TLS escape hatch for machines behind a TLS-intercepting / broken-
7
34
  // revocation proxy. Off by default. Prefer fixing the trust store.
8
35
  // LƯU Ý: gọi từ bin/noob.js SAU khi parse argv — vì ESM import được hoist nên
@@ -89,7 +116,7 @@ function hasUnclosedToolBlock(text) {
89
116
  *
90
117
  * @returns {Promise<{text:string, reasoning:string}>}
91
118
  */
92
- export async function stream({ mode = "chat", message, model, signal, onDelta, onReasoning, onStatus, idleMs = 120000, maxContinues = Infinity }) {
119
+ export async function stream({ mode = "chat", message, model, system, conversation, effort, signal, onDelta, onReasoning, onStatus, idleMs = 25000, maxContinues = Infinity }) {
93
120
  const endpoint = mode === "search" ? "/api/search" : mode === "merge" ? "/api/merge" : "/api/chat";
94
121
 
95
122
  let fullText = "";
@@ -99,7 +126,7 @@ export async function stream({ mode = "chat", message, model, signal, onDelta, o
99
126
  let emptyStreak = 0; // số lần liên tiếp stream rỗng (chống loop vô tận khi upstream chết hẳn)
100
127
 
101
128
  for (let attempt = 0; ; attempt++) {
102
- const r = await streamOnce({ endpoint, mode, message: prompt, model, signal, idleMs, onStatus, onDelta, onReasoning });
129
+ const r = await streamOnce({ endpoint, mode, message: prompt, model, system, conversation, effort, signal, idleMs, onStatus, onDelta, onReasoning });
103
130
  fullText = mode === "chat" ? fullText + r.text : r.text; // chat: ghép các đoạn nối tiếp; mode khác: thay thế
104
131
  if (r.reasoning) reasoning = r.reasoning;
105
132
 
@@ -148,8 +175,18 @@ function continuationPrompt(message, partial) {
148
175
  * One network attempt of the stream. Returns this attempt's accumulated text +
149
176
  * a `truncated` flag telling the caller whether the reply was cut short.
150
177
  */
151
- async function streamOnce({ endpoint, mode, message, model, signal, idleMs, onStatus, onDelta, onReasoning }) {
152
- const body = mode === "search" ? { query: message } : mode === "merge" ? { message } : { message, model };
178
+ async function streamOnce({ endpoint, mode, message, model, system, conversation, effort, signal, idleMs, onStatus, onDelta, onReasoning }) {
179
+ // chat body: gửi system + conversation riêng để gateway forward đúng tới upstream.
180
+ // Worker gateway (handleChat) + upstream đều nhận shape này.
181
+ let body;
182
+ if (mode === "search") body = { query: message };
183
+ else if (mode === "merge") body = { message };
184
+ else {
185
+ body = { message, model, remember: true, memoryToken: getMemoryToken() };
186
+ if (system) body.customInstructions = system;
187
+ if (Array.isArray(conversation) && conversation.length) body.conversation = conversation;
188
+ if (effort) body.effort = effort;
189
+ }
153
190
 
154
191
  // Idle-timeout: nếu KHÔNG nhận được byte nào trong idleMs (kết nối treo), tự
155
192
  // huỷ và báo lỗi rõ ràng — thay vì spinner quay vô tận. Vẫn tôn trọng signal
@@ -158,14 +195,65 @@ async function streamOnce({ endpoint, mode, message, model, signal, idleMs, onSt
158
195
  let timedOut = false;
159
196
  const onUserAbort = () => ctrl.abort();
160
197
  signal?.addEventListener("abort", onUserAbort, { once: true });
161
- let idle;
162
- const arm = () => {
163
- clearTimeout(idle);
164
- idle = setTimeout(() => {
198
+ // Hai loại timer tách rời (xem comment dài bên dưới):
199
+ // - WIRE: kết nối TCP còn thở không? Reset mỗi khi reader.read() trả bytes
200
+ // (kể cả comment SSE `: keepalive` từ worker). Ngưỡng cao (idleMs*2) — chỉ
201
+ // để bắt trường hợp socket chết cứng không còn cả heartbeat.
202
+ // - CONTENT: upstream có thực sự đang nghĩ/nói không? Reset chỉ khi parser
203
+ // thấy delta/status/reasoning/done/truncated thật. WARN/PROBE/idleMs gắn
204
+ // vào timer này. Đây là fix cho bug: worker phát `: keepalive\n\n` mỗi 15s
205
+ // bất kể upstream Vercel còn sống hay đã treo → nếu reset idle theo wire,
206
+ // status đếm thời gian sẽ chạy mãi không bao giờ trigger probe/abort.
207
+ let wireIdle = null;
208
+ let contentIdle = null;
209
+ let warnTimer = null;
210
+ let probeTimer = null;
211
+ let probeInFlight = false;
212
+ const WARN_MS = Math.min(8000, idleMs / 3);
213
+ const PROBE_MS = Math.min(12000, (idleMs * 2) / 3);
214
+ const WIRE_MS = idleMs * 2; // socket dead-cứng (mất cả heartbeat) — ngưỡng rộng
215
+ const clearContentTimers = () => {
216
+ clearTimeout(warnTimer); warnTimer = null;
217
+ clearTimeout(probeTimer); probeTimer = null;
218
+ clearTimeout(contentIdle); contentIdle = null;
219
+ };
220
+ const armContent = () => {
221
+ clearContentTimers();
222
+ warnTimer = setTimeout(() => {
223
+ if (onStatus) onStatus("Đang chờ proxy phản hồi…");
224
+ }, WARN_MS);
225
+ probeTimer = setTimeout(async () => {
226
+ if (probeInFlight || ctrl.signal.aborted) return;
227
+ probeInFlight = true;
228
+ try {
229
+ const probeCtl = new AbortController();
230
+ const probeT = setTimeout(() => probeCtl.abort(), 3000);
231
+ const r = await fetch(config.gatewayUrl + "/api/usage", { headers: authHeaders(), signal: probeCtl.signal });
232
+ clearTimeout(probeT);
233
+ if (!r.ok && r.status >= 500) throw new Error("proxy 5xx");
234
+ } catch {
235
+ if (!ctrl.signal.aborted) {
236
+ timedOut = true;
237
+ if (onStatus) onStatus("Proxy không phản hồi — đang gọi lại model…");
238
+ ctrl.abort();
239
+ }
240
+ } finally {
241
+ probeInFlight = false;
242
+ }
243
+ }, PROBE_MS);
244
+ contentIdle = setTimeout(() => {
165
245
  timedOut = true;
166
246
  ctrl.abort();
167
247
  }, idleMs);
168
248
  };
249
+ const armWire = () => {
250
+ clearTimeout(wireIdle);
251
+ wireIdle = setTimeout(() => {
252
+ // Mất cả heartbeat → socket chết cứng. Abort, lớp trên sẽ retry.
253
+ timedOut = true;
254
+ ctrl.abort();
255
+ }, WIRE_MS);
256
+ };
169
257
 
170
258
  let text = "";
171
259
  let reasoning = "";
@@ -184,6 +272,12 @@ async function streamOnce({ endpoint, mode, message, model, signal, idleMs, onSt
184
272
  } catch {
185
273
  return;
186
274
  }
275
+ // Có ít nhất 1 field nội dung thực → upstream đang nghĩ/nói. Reset content
276
+ // idle/warn/probe. JSON rỗng `{}` (ping) hoặc comment `: keepalive` của
277
+ // worker KHÔNG khớp điều kiện này → không che mất idle detection.
278
+ if (p.status || p.delta || p.reasoning || p.done || p.truncated || p.error) {
279
+ armContent();
280
+ }
187
281
  if (p.status && onStatus) onStatus(p.status);
188
282
  if (p.delta) {
189
283
  text += p.delta;
@@ -200,7 +294,8 @@ async function streamOnce({ endpoint, mode, message, model, signal, idleMs, onSt
200
294
  };
201
295
 
202
296
  try {
203
- arm();
297
+ armWire();
298
+ armContent();
204
299
  const resp = await fetch(config.gatewayUrl + endpoint, {
205
300
  method: "POST",
206
301
  headers: authHeaders(),
@@ -214,7 +309,7 @@ async function streamOnce({ endpoint, mode, message, model, signal, idleMs, onSt
214
309
  let buf = "";
215
310
  while (true) {
216
311
  const { done, value } = await reader.read();
217
- arm(); // hoạt động → gia hạn idle
312
+ armWire(); // bất kỳ byte nào tới socket còn thở, gia hạn wire idle
218
313
  if (done) break;
219
314
  buf += decoder.decode(value, { stream: true });
220
315
  let nl;
@@ -239,7 +334,8 @@ async function streamOnce({ endpoint, mode, message, model, signal, idleMs, onSt
239
334
  if (mode === "chat" && text) return { text, reasoning, truncated: true };
240
335
  throw err;
241
336
  } finally {
242
- clearTimeout(idle);
337
+ clearTimeout(wireIdle);
338
+ clearContentTimers();
243
339
  signal?.removeEventListener("abort", onUserAbort);
244
340
  }
245
341
  }
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
@@ -6,12 +6,12 @@ import { createTui } from "./tui.js";
6
6
  import { runAgent, maybeSummarize } from "./agent.js";
7
7
  import { runSubAgent, spawnAgentToolsDoc, MAX_SUBAGENT_DEPTH } from "./subagent.js";
8
8
  import { TokenMeter } from "./tokens.js";
9
- import { stream, usage, ApiError } from "./api.js";
9
+ import { stream, usage, ApiError, resetMemoryToken } from "./api.js";
10
10
  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";
@@ -360,7 +360,11 @@ export async function startRepl(opts = {}) {
360
360
  );
361
361
  console.log(c.dim("\n " + t.sessionResumeHint) + "\n");
362
362
  }
363
- const startFresh = () => (session = sessions.newSession({ cwd: process.cwd(), model: state.model.id }));
363
+ const startFresh = () => {
364
+ session = sessions.newSession({ cwd: process.cwd(), model: state.model.id });
365
+ // Reset per-session upstream memory token so the next chat starts fresh.
366
+ resetMemoryToken();
367
+ };
364
368
 
365
369
  // /frontend-design <yêu cầu> — vận dụng skill frontend-design (skills/frontend-design/SKILL.md)
366
370
  // để model tạo UI frontend chất lượng cao, tránh "AI slop" aesthetic.
@@ -535,6 +539,20 @@ Cuối cùng: tóm tắt + danh sách sửa theo thứ tự ưu tiên. Thẳng t
535
539
  // bắt nhầm khi model chỉ NHẮC tới token giữa văn xuôi. Không bao giờ chấp nhận
536
540
  // ở lượt lập kế hoạch (xem vòng lặp bên dưới).
537
541
  const ultraIsDone = (a) => a.trimEnd().endsWith(ULTRA_DONE);
542
+ // Detect "stuck": model bối rối, không nhận task, chỉ hỏi lại user hoặc spam
543
+ // list_dir/ls vô nghĩa. Xảy ra khi goal trống nghĩa / bị paste system prompt /
544
+ // model mất ngữ cảnh. 2 vòng stuck liên tiếp → auto-exit để không loop vô hạn.
545
+ const STUCK_PHRASES = [
546
+ "chưa giao task", "chưa nêu tác vụ", "chưa có yêu cầu", "chưa có task",
547
+ "không nhận task", "không thể nhận vai", "bạn muốn mình làm gì",
548
+ "chưa rõ yêu cầu", "cần mục tiêu rõ", "vui lòng cho biết",
549
+ "please provide", "what would you like", "no task", "clarify",
550
+ ];
551
+ const ultraLooksStuck = (a) => {
552
+ if (!a) return true;
553
+ const s = a.toLowerCase();
554
+ return STUCK_PHRASES.some((p) => s.includes(p));
555
+ };
538
556
  const ultraStart = (goal) => `# CHẾ ĐỘ ULTRA (tự hành)
539
557
  Mục tiêu tổng: ${goal}
540
558
 
@@ -568,10 +586,23 @@ Tự đánh giá: còn THIẾU gì để đạt mục tiêu? Chọn 1 nhiệm v
568
586
  let answer = await handle(ultraStart(goal));
569
587
  persist();
570
588
  let i = 0;
589
+ let stuckStreak = 0; // đếm vòng liên tiếp model bối rối / không nhận task
590
+ const STUCK_MAX = 2;
571
591
  // Lượt đầu = lập kế hoạch → KHÔNG xét hoàn thành. Mỗi vòng sau là một lượt
572
592
  // "tiếp tục" có cổng kiểm chứng; chỉ dừng khi token nằm ở CUỐI câu trả lời.
593
+ // Cũng dừng sớm nếu model 2 vòng liên tiếp tỏ ra mất ngữ cảnh (goal trống
594
+ // nghĩa, paste system prompt, model hỏi lại user) → tránh spam list_dir.
573
595
  while (state.ultra && i < MAX_QUESTS) {
574
596
  if (!answer) break; // lượt bị ngắt/ lỗi → dừng tự hành, đừng quay vô ích
597
+ if (ultraLooksStuck(answer)) {
598
+ stuckStreak++;
599
+ if (stuckStreak >= STUCK_MAX) {
600
+ console.log(c.err(" ⚠ ULTRA stuck: model không nhận task " + stuckStreak + " vòng liên tiếp. Thoát. Gõ /ultra <mục tiêu rõ> để thử lại."));
601
+ break;
602
+ }
603
+ } else {
604
+ stuckStreak = 0;
605
+ }
575
606
  i++;
576
607
  console.log(c.accent(" ↻ " + t.ultraQuest(i)));
577
608
  answer = await handle(ultraContinue(goal));
@@ -841,6 +872,17 @@ NGUYÊN TẮC:
841
872
  tui.start();
842
873
  banner();
843
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
+ }
844
886
  if (!config.apiKey) console.log("\n" + c.tool(" " + t.notLoggedIn) + "\n");
845
887
  else console.log(c.dim(" " + t.ready + "\n"));
846
888
 
@@ -1045,6 +1087,10 @@ NGUYÊN TẮC:
1045
1087
  signal: abort.signal,
1046
1088
  tokenMeter,
1047
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),
1048
1094
  extraToolsDoc: state.agentMode ? spawnAgentToolsDoc(0) : "",
1049
1095
  onStatus: () => tick(t.thinking),
1050
1096
  onSteer: () => {
@@ -1313,6 +1359,15 @@ NGUYÊN TẮC:
1313
1359
  break;
1314
1360
  case "clear":
1315
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
+ }
1316
1371
  persist(); // giữ lại phiên cũ trên đĩa
1317
1372
  state.history = [];
1318
1373
  state._longSessionWarned = false; // reset cờ cảnh báo phiên dài
@@ -1320,6 +1375,13 @@ NGUYÊN TẮC:
1320
1375
  if (!tui.tty) console.clear();
1321
1376
  banner();
1322
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
+ }
1323
1385
  console.log(c.dim(" " + t.ctxCleared + "\n"));
1324
1386
  break;
1325
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,105 @@ 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;
140
216
  function renderBar() {
141
217
  const promptW = visLen(promptLabel);
142
218
  const budget = Math.max(4, cols() - promptW - 1);
219
+ const indent = " ".repeat(promptW);
143
220
  const plains = cells.map(cellPlain);
144
- let curCol = 0;
145
- for (let k = 0; k < cur; k++) curCol += plains[k].length;
221
+ const plain = plains.join("");
146
222
  let total = 0;
147
223
  for (const p of plains) total += p.length;
224
+ let curCharPos = 0;
225
+ for (let k = 0; k < cur; k++) curCharPos += plains[k].length;
226
+
227
+ // Vừa khung → 1 dòng như cũ, con trỏ map thẳng.
148
228
  if (total <= budget) {
149
- cursorScreenCol = promptW + curCol;
229
+ cursorScreenRow = 0;
230
+ cursorScreenCol = promptW + curCharPos;
150
231
  return promptLabel + coloredInput();
151
232
  }
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("");
233
+
234
+ // Tràn khung soft-wrap dọc tối đa MAX_BAR_LINES, ưu tiên cắt tại space.
235
+ // Dòng 0 promptLabel; các dòng sau thụt vào `indent` để con trỏ thẳng
236
+ // cột với text sau prompt. Paste chip đếm như 1 "từ" dài `[pasted N lines]`
237
+ // nếu không vừa, đẩy nguyên chip sang dòng mới (vẫn đọc được).
238
+ const MAX_BAR_LINES = 5;
239
+ const lines = [];
240
+ let pos = 0;
241
+ while (pos < total && lines.length < MAX_BAR_LINES) {
242
+ const remaining = total - pos;
243
+ if (remaining <= budget) {
244
+ lines.push(plain.slice(pos));
245
+ break;
246
+ }
247
+ // Tìm space gần nhất trong slice [pos, pos+budget]; nếu > 30% budget → cắt
248
+ // tại đó. Không có space hợp lý → hard-slice theo budget.
249
+ const slice = plain.slice(pos, pos + budget);
250
+ const lastSpace = slice.lastIndexOf(" ");
251
+ let cutLen = budget;
252
+ if (lastSpace > budget * 0.3) cutLen = lastSpace;
253
+ lines.push(slice.slice(0, cutLen).trimEnd());
254
+ pos += cutLen;
255
+ while (pos < total && plain[pos] === " ") pos++; // bỏ space đầu dòng sau
256
+ }
257
+ // Nếu vẫn còn dư → đánh dấu dòng cuối bị truncate.
258
+ if (pos < total && lines.length) {
259
+ const last = lines.length - 1;
260
+ const lastLine = lines[last];
261
+ lines[last] = (lastLine.length >= budget ? lastLine.slice(0, -1) : lastLine) + "…";
262
+ }
263
+
264
+ // Map curCharPos → (line, colInLine).
265
+ let curLine = 0;
266
+ let acc = 0;
267
+ for (let i = 0; i < lines.length; i++) {
268
+ const lineLen = lines[i].length;
269
+ if (curCharPos <= acc + lineLen || i === lines.length - 1) {
270
+ curLine = i;
271
+ break;
272
+ }
273
+ acc += lineLen;
274
+ }
275
+ const colInLine = Math.min(curCharPos - acc, lines[curLine].length);
276
+ cursorScreenRow = curLine;
277
+ cursorScreenCol = promptW + colInLine;
278
+
279
+ return lines.map((l, i) => (i === 0 ? promptLabel : indent) + l).join("\n");
162
280
  }
163
281
  function topRow() {
164
282
  if (liveOut) {
@@ -166,17 +284,17 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
166
284
  // CHỈ KHI liveOut đủ ngắn để cả dòng (liveOut + meta) chắc chắn fit trong
167
285
  // 1 dòng terminal. Nếu liveOut quá dài → ưu tiên prose, bỏ meta lượt này
168
286
  // (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).
287
+ // Note "token meter chèn vào prose" trong noob.md). Trường hợp wrap nhiều
288
+ // dòng: chỉ dòng đầu ghép meta (nếu vừa), các dòng sau prose thuần.
170
289
  if (busy && busyMeta) {
171
290
  const meta = c.dim(" · " + busyMeta);
172
291
  const metaLen = visLen(meta);
173
- const liveLen = visLen(liveOut);
174
- if (liveLen + metaLen <= cols()) {
175
- return liveOut + meta;
292
+ if (visLen(liveOut) + metaLen <= cols()) {
293
+ return [liveOut + meta];
176
294
  }
177
- // liveOut đã dài: hiện prose nguyên trạng, bỏ meta để tránh wrap.
295
+ // liveOut đã dài: meta sẽ bị bỏ (xem note trên).
178
296
  }
179
- return liveOut.length > cols() ? liveOut.slice(0, cols()) : liveOut;
297
+ return wrapText(liveOut, cols(), 2);
180
298
  }
181
299
  const spin = FRAMES[frame % FRAMES.length];
182
300
  // Khi busy, LUÔN hiện elapsed+tokens (busyMeta) cạnh statusText/busyLabel để
@@ -185,12 +303,12 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
185
303
  const meta = busy && busyMeta ? c.dim(" · " + busyMeta) : "";
186
304
  const tail = busy ? c.dim(" · Ctrl+C để dừng") : "";
187
305
  const line = c.dim(spin + " ") + statusText + meta + tail;
188
- return line.length > cols() ? line.slice(0, cols()) : line;
306
+ return wrapText(line, cols(), 2);
189
307
  }
190
308
  if (busy) {
191
309
  const meta = busyMeta ? " · " + busyMeta : "";
192
310
  const line = c.dim(spin + " " + (busyLabel || "đang chạy") + meta + " · Ctrl+C để dừng");
193
- return line.length > cols() ? line.slice(0, cols()) : line;
311
+ return wrapText(line, cols(), 2);
194
312
  }
195
313
  return null;
196
314
  }
@@ -210,21 +328,29 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
210
328
  function rows() {
211
329
  const r = [];
212
330
  const top = topRow();
213
- if (top !== null) r.push(top);
331
+ if (top !== null) r.push(...top);
214
332
  for (const mr of menuRows()) r.push(mr);
215
- r.push(renderBar());
333
+ const bar = renderBar();
334
+ if (bar) r.push(...bar.split("\n"));
216
335
  return r;
217
336
  }
218
337
  function eraseSeq() {
219
338
  if (!drawn) return "\r";
220
339
  return "\r" + (prevRows > 1 ? `${ESC}[${prevRows - 1}A` : "") + `${ESC}[J`;
221
340
  }
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` : "");
341
+ // Sau khi vẽ xong các hàng, con trỏ đang ở CUỐI thanh (hàng cuối, cột cuối).
342
+ // Đưa về đúng (row, col) của con trỏ logic: \r về cột 0 đi lên `upBy` hàng
343
+ // (nếu thanh wrap nhiều dòng) dịch phải `cursorScreenCol` cột.
344
+ const placeCursor = (totalRows) => {
345
+ const upBy = totalRows - 1 - cursorScreenRow;
346
+ let s = "\r";
347
+ if (upBy > 0) s += `${ESC}[${upBy}A`;
348
+ if (cursorScreenCol > 0) s += `${ESC}[${cursorScreenCol}C`;
349
+ return s;
350
+ };
225
351
  function draw() {
226
- const rs = rows(); // rows() → renderBar() cập nhật cursorScreenCol
227
- w(`${ESC}[?25l` + eraseSeq() + rs.join("\n") + placeCursor() + `${ESC}[?25h`);
352
+ const rs = rows(); // rows() → renderBar() cập nhật cursorScreenRow/Col
353
+ w(`${ESC}[?25l` + eraseSeq() + rs.join("\n") + placeCursor(rs.length) + `${ESC}[?25h`);
228
354
  prevRows = rs.length;
229
355
  drawn = true;
230
356
  }
@@ -234,7 +360,7 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
234
360
  let s = `${ESC}[?25l` + eraseSeq();
235
361
  s += block;
236
362
  if (!block.endsWith("\n")) s += "\n";
237
- s += rs.join("\n") + placeCursor() + `${ESC}[?25h`;
363
+ s += rs.join("\n") + placeCursor(rs.length) + `${ESC}[?25h`;
238
364
  w(s);
239
365
  prevRows = rs.length;
240
366
  drawn = true;