@noobdemon/noob-cli 1.11.1 → 1.12.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/src/repl.js CHANGED
@@ -6,7 +6,7 @@ import { createTui } from './tui.js';
6
6
  import { runAgent, maybeSummarize, buildSystem, buildUserMessage } from './agent.js';
7
7
  import { runSubAgent, spawnAgentToolsDoc, MAX_SUBAGENT_DEPTH } from './subagent.js';
8
8
  import { TokenMeter, countMessages, CONTEXT_WINDOW, countTokens } from './tokens.js';
9
- import { stream, usage, ApiError, resetMemoryToken } from './api.js';
9
+ import { stream, usage, cachedUsage, resetUsageCache, ApiError, resetMemoryToken } from './api.js';
10
10
  import {
11
11
  runTool,
12
12
  describe,
@@ -18,7 +18,17 @@ import {
18
18
  nearestExistingDir,
19
19
  } from './tools.js';
20
20
  import { MODELS, PROVIDERS, findModel, providerColor, DEFAULT_MODEL } from './models.js';
21
- import { c, banner, modelBadge, renderMarkdown, box } from './ui.js';
21
+ import { c, banner, modelBadge, renderMarkdown, renderInline, renderHeadingLine, renderBulletPrefix, box, formatQuota } from './ui.js';
22
+ import { renderUnifiedDiff, renderNewFilePreview } from './diff.js';
23
+ import {
24
+ askPermission as _askPermission,
25
+ askAddRoot as _askAddRoot,
26
+ askWorkflowAgentMode as _askWorkflowAgentMode,
27
+ } from './repl/permission.js';
28
+ import {
29
+ runImprove as _runImprove,
30
+ runKarpathy as _runKarpathy,
31
+ } from './repl/commands/prompts.js';
22
32
  import { config } from './config.js';
23
33
  import { loadMemory, memoryPath, memoryStats } from './memory.js';
24
34
  import { t } from './i18n.js';
@@ -26,6 +36,17 @@ import { checkLatest, runUpdate, CURRENT } from './update.js';
26
36
  import * as sessions from './sessions.js';
27
37
  import { loadSkill, listSkills } from './skills.js';
28
38
  import { saveWorkflow, loadWorkflow } from './workflows.js';
39
+ import {
40
+ createRun as createWorkflowRun,
41
+ loadRun as loadWorkflowRun,
42
+ listRuns as listWorkflowRuns,
43
+ closeRun as closeWorkflowRun,
44
+ hashTask as hashWorkflowTask,
45
+ lookupTaskResult as lookupWorkflowTaskResult,
46
+ recordTaskStart as recordWorkflowTaskStart,
47
+ recordTaskDone as recordWorkflowTaskDone,
48
+ recordTaskFailed as recordWorkflowTaskFailed,
49
+ } from './workflow-runs.js';
29
50
  import { getBuiltinWorkflow, loadBuiltinPrompt } from './workflows-builtin.js';
30
51
  import { SLASH, completeInput, mentionedFiles } from './repl/complete.js';
31
52
  import { parseTodosFromHistory } from './repl/todos.js';
@@ -45,22 +66,12 @@ import {
45
66
  workflowLoad as _workflowLoad,
46
67
  workflowDelete as _workflowDelete,
47
68
  } from './repl/workflow-commands.js';
69
+ import { createState } from './repl/state.js';
70
+ import {
71
+ shortCwd, shortPath, relTime, firstLine, truncate, fmtTime, fmtK, preview,
72
+ } from './repl/utils.js';
48
73
  export async function startRepl(opts = {}) {
49
- const state = {
50
- model: findModel(opts.model) || findModel(config.model) || findModel(DEFAULT_MODEL),
51
- mode: 'chat', // chat | merge | search
52
- history: [],
53
- autoApprove: new Set(),
54
- yolo: !!opts.yolo || config.yoloDefault, // cờ --yolo HOẶC mặc định đã lưu (/auto-yolo)
55
- ultra: false, // chế độ tự hành (self-quest) đang chạy?
56
- agentMode: false, // /agent on → cho phép spawn_agent / spawn_agents
57
- goal: null, // HARD GOAL (set qua /goal <text>) — inject vào mọi prompt tới khi /goal clear
58
- loop: null, // /loop — {intervalMs, intervalStr, task, timer, ticks, startedAt} hoặc null
59
- extraRoots: [], // thư mục ngoài cwd được /add-dir cấp quyền (UX display only;
60
- // source of truth là extraRoots trong src/tools.js)
61
- _longSessionWarned: false, // đã in cảnh báo phiên dài chưa (reset khi /clear)
62
- todos: [], // [{text, done}] — todo list parse từ model output, render trên status bar
63
- };
74
+ const state = createState(opts, config);
64
75
  const tokenMeter = new TokenMeter();
65
76
 
66
77
  // Set terminal title bar — hiện trên CMD/PowerShell.
@@ -127,6 +138,23 @@ export async function startRepl(opts = {}) {
127
138
  return tui.read(prompt);
128
139
  }
129
140
 
141
+ // ── permission prompts (y/n/a) ────────────────────────────────────────
142
+ // PHẢI khai báo Ở ĐÂY (ngay sau `ask`), TRƯỚC bất kỳ function nào có thể
143
+ // gọi tới — trước đây 3 const này nằm rải rác (line ~405, ~1648, ~1655)
144
+ // gây TDZ ReferenceError: "Cannot access 'askAddRoot' before initialization"
145
+ // khi execToolCore / workflowExecute chạy lần đầu. Logic vẫn ở permission.js,
146
+ // chỉ wire prompt UI (tui/ask/pending/...) từ scope startRepl.
147
+ //
148
+ // ĐỪNG DI CHUYỂN khối này lên trên: dependencies cần sẵn — `tui` (~line 112),
149
+ // `pending` (~line 107), `ask` (~line 136), `c`/`t`/`truncate` (import top).
150
+ // Nếu dời lên trước `ask` sẽ lại TDZ. Vị trí hiện tại là điểm sớm nhất hợp lệ.
151
+ const askPermission = (name) =>
152
+ _askPermission(name, { tui, ask, pending, c, t, truncate });
153
+ const askAddRoot = (root, targetPath) =>
154
+ _askAddRoot(root, targetPath, { tui, ask, pending, c, t, truncate });
155
+ const askWorkflowAgentMode = () =>
156
+ _askWorkflowAgentMode({ tui, ask, pending, c, t, truncate });
157
+
130
158
  // NOOB_DEBUG=1: vạch trần đường thoát thật sự (xác nhận/loại bỏ giả thuyết
131
159
  // "event loop cạn"). 'beforeExit' nổ = loop cạn (stdin chết). 'exit' = thoát.
132
160
  if (process.env.NOOB_DEBUG === '1') {
@@ -367,7 +395,7 @@ Thực thi: đọc/tạo file cần thiết bằng tool, viết code production-
367
395
  // match quan trọng: `help` / `?` / `patterns` / `builtins` / `list|ls` /
368
396
  // `load` / `delete|rm` / `save` / `run`. Ad-hoc default = phần còn lại.
369
397
  const m = trimmed.match(
370
- /^(help|\?|patterns|builtins|list|ls|load|delete|rm|save|run)\b\s*([\s\S]*)$/i
398
+ /^(help|\?|patterns|builtins|list|ls|load|delete|rm|save|run|runs|log|resume)\b\s*([\s\S]*)$/i
371
399
  );
372
400
  if (m) {
373
401
  const sub = m[1].toLowerCase();
@@ -380,47 +408,18 @@ Thực thi: đọc/tạo file cần thiết bằng tool, viết code production-
380
408
  if (sub === 'delete' || sub === 'rm') return workflowDelete(rest);
381
409
  if (sub === 'save') return workflowSave(rest);
382
410
  if (sub === 'run') return workflowRun(rest);
411
+ if (sub === 'runs') return workflowRuns();
412
+ if (sub === 'log') return workflowLog(rest);
413
+ if (sub === 'resume') return workflowResume(rest);
383
414
  }
384
415
  // Default: ad-hoc workflow (giữ behavior cũ — model design workflow từ request).
385
416
  await workflowExecute(trimmed);
386
417
  }
387
418
 
388
- // Hỏi quyền bật agent mode để chạy workflow. CHỈ chấp nhận y/n (Enter = yes).
389
- // Nếu nhận dòng lạ & dài (paste nhầm tin nhắn) → xếp hàng + hỏi lại (y hệt
390
- // askPermission / askAddRoot) để user khỏi phải gõ lại /workflow.
391
- async function askWorkflowAgentMode() {
392
- tui.setBusy(false);
393
- console.log(
394
- c.tool(
395
- ' ' +
396
- (t.workflowAgentAskHint || '🎼 /workflow cần spawn sub-agent — agent mode hiện đang TẮT.')
397
- )
398
- );
399
- try {
400
- while (true) {
401
- const raw = await ask(
402
- c.tool(' bật agent mode và chạy workflow? ') +
403
- c.dim('[y] có, bật & chạy / [n] huỷ (gõ /agent rồi chạy lại nếu muốn) › ')
404
- );
405
- if (raw == null) return 'n'; // stdin đóng thật
406
- const a = raw.trim().toLowerCase();
407
- if (a === '' || a === 'y' || a === 'yes' || a === 'có') return 'y';
408
- if (a === 'n' || a === 'no' || a === 'không') return 'n';
409
- if (raw.trim().length > 3) {
410
- pending.push(raw);
411
- console.log(c.dim(' ' + t.queued(pending.length, truncate(raw, 60))));
412
- }
413
- console.log(c.dim(' → gõ y hoặc n'));
414
- }
415
- } finally {
416
- tui.setBusy(true, t.thinking);
417
- }
418
- }
419
-
420
419
  // Chạy thật workflow prompt — chia sẻ giữa ad-hoc và `run <name>`.
421
420
  // `builtInName` (optional): nếu có thì SKIP loadSkill dynamic-workflows (prompt
422
421
  // built-in đã hardcode pattern + step cụ thể rồi, không cần model design lại).
423
- async function workflowExecute(userRequest, { builtInName = null } = {}) {
422
+ async function workflowExecute(userRequest, { builtInName = null, resumeRun = null } = {}) {
424
423
  let prompt;
425
424
  if (builtInName) {
426
425
  // Built-in prompt đã đầy đủ PLAN + 4 bước thực thi — KHÔNG wrap thêm skill.
@@ -472,8 +471,57 @@ Thực thi THEO ĐÚNG THỨ TỰ (BẮT BUỘC):
472
471
  c.tool(' ✓ ' + (t.workflowAgentEnabled || 'đã bật agent mode cho workflow này.'))
473
472
  );
474
473
  }
474
+ // Mở workflow run journal — track mỗi sub-agent task để resume sau interrupt.
475
+ // resumeRun: data đã load sẵn từ /workflow resume <id> → tái dùng id cũ.
476
+ // Còn lại: tạo run mới (ad-hoc hoặc built-in/saved).
477
+ try {
478
+ if (resumeRun) {
479
+ state.workflowRun = { id: resumeRun.id, data: resumeRun, path: null };
480
+ // Đánh dấu lại running (trước đó là interrupted/failed).
481
+ resumeRun.status = 'running';
482
+ const doneCount = (resumeRun.tasks || []).filter((tk) => tk.status === 'done').length;
483
+ console.log(
484
+ c.tool(
485
+ ' 🎼 resume run ' +
486
+ c.accent(resumeRun.id) +
487
+ c.dim(` · ${doneCount}/${(resumeRun.tasks || []).length} task đã done sẽ được skip\n`)
488
+ )
489
+ );
490
+ } else {
491
+ const run = createWorkflowRun({
492
+ name: builtInName || 'adhoc',
493
+ workflowPrompt: prompt,
494
+ });
495
+ state.workflowRun = run;
496
+ console.log(c.dim(' 📓 workflow run journal: ' + run.id));
497
+ }
498
+ } catch (e) {
499
+ // Journal lỗi (vd EACCES /.noob/) → vẫn chạy workflow như cũ, mất tính năng resume thôi.
500
+ state.workflowRun = null;
501
+ console.log(c.dim(' (không khởi tạo được workflow journal: ' + (e?.message || e) + ')'));
502
+ }
475
503
  console.log(c.tool(' 🎼 ' + (t.workflowRunning || 'Dynamic workflow running…')));
476
- await handle(prompt);
504
+ try {
505
+ await handle(prompt);
506
+ // handle() xong sạch → đóng journal với status done.
507
+ if (state.workflowRun) closeWorkflowRun(state.workflowRun.data, 'done');
508
+ } catch (err) {
509
+ // Interrupt (Ctrl+C) hoặc lỗi khác → đánh dấu interrupted, journal vẫn đầy đủ để resume.
510
+ if (state.workflowRun) {
511
+ const isAbort = err?.message === 'aborted' || err?.name === 'AbortError';
512
+ closeWorkflowRun(state.workflowRun.data, isAbort ? 'interrupted' : 'failed');
513
+ console.log(
514
+ c.tool(
515
+ ' 📓 run ' +
516
+ c.accent(state.workflowRun.id) +
517
+ c.dim(' đã lưu — resume bằng `/workflow resume ' + state.workflowRun.id + '`')
518
+ )
519
+ );
520
+ }
521
+ throw err;
522
+ } finally {
523
+ state.workflowRun = null;
524
+ }
477
525
  persist();
478
526
  }
479
527
 
@@ -662,39 +710,83 @@ Thực thi THEO ĐÚNG THỨ TỰ (BẮT BUỘC):
662
710
  await workflowExecute(userRequest);
663
711
  }
664
712
 
665
- // /improve [hint]model soát workspace & đề xuất tính năng/cải tiến.
666
- // KHÔNG sửa code, chỉ phân tích & đề xuất.
667
- async function runImprove(arg) {
668
- if (!config.apiKey) return console.log(c.tool(' ' + t.notLoggedIn));
669
- const focus = arg
670
- ? `\nNgười dùng nhấn mạnh: "${arg}". Ưu tiên theo hướng đó nhưng vẫn nêu gợi ý quan trọng khác.`
671
- : '';
672
- const prompt = `Đóng vai senior engineer & product reviewer. KHẢO SÁT workspace hiện tại và đề xuất TÍNH NĂNG / CẢI TIẾN cho dự án.${focus}\n\nQUY TRÌNH (dùng tool, không nói suông):\n1. list_dir thư mục gốc để nắm cấu trúc.\n2. Đọc README.md, package.json, noob.md, CHANGELOG.md (nếu có) để hiểu mục đích & trạng thái.\n3. list_dir/glob các thư mục mã chính. KHÔNG đọc hết file — chỉ đủ để nắm kiến trúc.\n4. grep TODO/FIXME/HACK/XXX để biết chỗ tác giả đã ghi nhận.\n5. Ghi nhận thiếu test/lint/CI nếu có.\n\nSAU KHẢO SÁT, viết báo cáo Markdown TIẾNG VIỆT theo cấu trúc:\n\n## Tóm tắt dự án\n2–4 dòng: làm gì, tech gì, trạng thái.\n\n## Điểm mạnh hiện tại\n3–6 gạch đầu dòng.\n\n## Gợi ý cải thiện\n5–10 đề xuất, MỖI cái:\n### N. <Tên>\n- **Vấn đề/cơ hội:** quan sát cụ thể (kèm tên_file:dòng nếu được).\n- **Đề xuất:** mô tả tính năng/cải tiến.\n- **Lợi ích:** UX/hiệu năng/độ tin cậy/mở rộng.\n- **Công sức:** S (vài giờ) / M (1–2 ngày) / L (>2 ngày).\n- **Ưu tiên:** P0 / P1 / P2.\n\n## Đề xuất ưu tiên hàng đầu\n1–3 mục P0 nên làm trước, kèm lý do.\n\nQUY TẮC: bám observation từ code thật, KHÔNG gợi ý chung chung, thẳng thắn không nịnh, KHÔNG sửa code, KHÔNG ghi noob.md.`;
673
- console.log(c.tool(' ' + t.improveRunning));
674
- await handle(prompt);
675
- persist();
713
+ // /workflow runslist các workflow run journal trong workspace hiện tại.
714
+ function workflowRuns() {
715
+ const items = listWorkflowRuns(20);
716
+ if (!items.length) {
717
+ console.log(c.dim(' (chưa workflow run nào trong workspace này)'));
718
+ console.log(c.dim(' Run đầu tiên được tạo khi bạn /workflow <yêu cầu> hoặc /workflow run <name>.'));
719
+ return;
720
+ }
721
+ console.log('\n' + chalk.bold(' ' + (t.workflowRunsTitle || '📓 Workflow runs (workspace này)')));
722
+ for (const r of items) {
723
+ const statusColor =
724
+ r.status === 'done' ? c.ok :
725
+ r.status === 'interrupted' ? c.tool :
726
+ r.status === 'failed' ? c.err :
727
+ c.accent;
728
+ console.log(
729
+ c.dim(' ') +
730
+ c.accent(r.id.padEnd(40)) +
731
+ statusColor(r.status.padEnd(13)) +
732
+ c.dim(`${r.done}/${r.total} task done · ${relTime(r.updatedAt)}`)
733
+ );
734
+ }
735
+ console.log(
736
+ c.dim('\n /workflow log <id> xem chi tiết\n /workflow resume <id> chạy lại, skip task đã done\n')
737
+ );
676
738
  }
677
739
 
678
- // /karpathy [path]bắt noob tự soát code theo 4 nguyên tắc Karpathy.
679
- // Không có path → soát các file đã đổi trong phiên (model thấy qua FILES CHANGED).
680
- async function runKarpathy(arg) {
681
- if (!config.apiKey) return console.log(c.tool(' ' + t.notLoggedIn));
682
- const target = arg
683
- ? `file/đường dẫn: ${arg}`
684
- : 'các file bạn đã tạo/sửa trong phiên này (xem mục FILES CHANGED)';
685
- const prompt = `Đóng vai reviewer khó tính. Rà soát ${target} theo 4 nguyên tắc code của Karpathy.
686
- ĐỌC nội dung file thật bằng read_file trước — KHÔNG dựa vào trí nhớ.
687
- Với MỖI nguyên tắc, cho verdict (✅ đạt / ⚠️ cảnh báo / ❌ vi phạm) + phát hiện cụ thể kèm "tên_file:dòng":
688
- 1. THINK FIRST giả định ẩn nào chưa nêu? chỗ nào thiếu kiểm chứng?
689
- 2. KEEP IT SIMPLE — over-engineer, abstraction thừa, lồng quá sâu, hàm quá dài?
690
- 3. SURGICAL — thay đổi lạc đề, refactor tiện tay, đổi style/format vô cớ?
691
- 4. VERIFIABLE GOAL mục tiêu có kiểm chứng được? đã chạy build/test chưa?
692
- Cuối cùng: tóm tắt + danh sách sửa theo thứ tự ưu tiên. Thẳng thắn, KHÔNG nịnh.`;
693
- console.log(c.tool(' ⚖ Karpathy check…'));
694
- await handle(prompt);
695
- persist();
740
+ // /workflow log <id> xem chi tiết 1 run: prompt, tasks, kết quả mỗi sub-agent.
741
+ function workflowLog(arg) {
742
+ if (!arg) return console.log(c.err(' Cách dùng: /workflow log <id> (xem id bằng /workflow runs)'));
743
+ const r = loadWorkflowRun(arg);
744
+ if (!r.ok) return console.log(c.err(' Không tìm thấy run: ' + arg));
745
+ const d = r.data;
746
+ console.log('\n' + chalk.bold(' 📓 ' + d.id));
747
+ console.log(c.dim(` name: ${d.name || '(adhoc)'} · status: ${d.status} · started: ${relTime(d.startedAt)} · updated: ${relTime(d.updatedAt)}`));
748
+ console.log(c.dim(' workflow prompt: ' + truncate(d.workflowPrompt || '', 120)));
749
+ if (!d.tasks?.length) {
750
+ console.log(c.dim(' (chưa sub-agent task nào được ghi nhận)'));
751
+ return;
752
+ }
753
+ console.log('\n' + c.dim(' ── sub-agent tasks ──'));
754
+ d.tasks.forEach((tk, i) => {
755
+ const statusColor =
756
+ tk.status === 'done' ? c.ok :
757
+ tk.status === 'failed' ? c.err :
758
+ c.tool;
759
+ console.log(
760
+ c.dim(` #${i + 1} `) +
761
+ statusColor(tk.status.padEnd(8)) +
762
+ c.dim(tk.hash + ' ') +
763
+ c.accent(truncate(tk.task, 80))
764
+ );
765
+ if (tk.context) console.log(c.dim(' context: ' + truncate(tk.context, 80)));
766
+ if (tk.model) console.log(c.dim(' model: ' + tk.model));
767
+ if (tk.result) console.log(c.dim(' result: ' + truncate(tk.result.replace(/\s+/g, ' '), 120)));
768
+ if (tk.error) console.log(c.err(' error: ' + truncate(tk.error, 120)));
769
+ });
770
+ console.log('');
696
771
  }
697
772
 
773
+ // /workflow resume <id> — chạy lại workflow với cùng prompt; dispatchTool tự
774
+ // hit cache trong journal cho mọi task đã done lần trước → tiết kiệm token.
775
+ async function workflowResume(arg) {
776
+ if (!arg) return console.log(c.err(' Cách dùng: /workflow resume <id> (xem id bằng /workflow runs)'));
777
+ const r = loadWorkflowRun(arg);
778
+ if (!r.ok) return console.log(c.err(' Không tìm thấy run: ' + arg));
779
+ const d = r.data;
780
+ if (!d.workflowPrompt) return console.log(c.err(' Run này không có workflow prompt — không thể resume.'));
781
+ if (d.status === 'done') {
782
+ console.log(c.tool(' ⚠ run đã ở trạng thái DONE — vẫn resume nhưng có thể chạy lại task mới nếu model spawn khác.'));
783
+ }
784
+ await workflowExecute(d.workflowPrompt, { resumeRun: d });
785
+ }
786
+
787
+ const runImprove = (arg) => _runImprove(arg, { config, c, t, handle, persist });
788
+ const runKarpathy = (arg) => _runKarpathy(arg, { config, c, t, handle, persist });
789
+
698
790
  // ── ULTRA: chế độ tự hành (self-quest) ────────────────────────────────────
699
791
  // Constants + helpers thuần + prompt templates đã tách sang src/repl/ultra.js.
700
792
  // Phần state-heavy (runUltra loop) ở dưới giữ ở đây vì cần closure handle/persist/state.
@@ -1062,6 +1154,22 @@ NGUYÊN TẮC:
1062
1154
  .catch(() => {});
1063
1155
  }
1064
1156
 
1157
+ // Prefetch usage để status bar có quota ngay từ phút đầu. Cache TTL 90s ở
1158
+ // api.js → status bar tick mỗi giây chỉ đọc cachedUsage() sync, không gọi
1159
+ // gateway. Refresh nền mỗi 90s để số luôn tươi mà không spam.
1160
+ // Chạy hoàn toàn non-blocking; lỗi (offline, bad key) → bỏ qua, status bar
1161
+ // chỉ thiếu phần quota tail, không ảnh hưởng phần còn lại.
1162
+ let usageRefreshTimer = null;
1163
+ const refreshUsage = () => {
1164
+ if (!config.apiKey) return;
1165
+ usage({ force: true }).catch(() => {});
1166
+ };
1167
+ if (config.apiKey) {
1168
+ refreshUsage();
1169
+ usageRefreshTimer = setInterval(refreshUsage, 90_000);
1170
+ if (typeof usageRefreshTimer.unref === 'function') usageRefreshTimer.unref();
1171
+ }
1172
+
1065
1173
  // Khôi phục phiên theo cờ dòng lệnh, hoặc mở phiên mới.
1066
1174
  if (opts.continue) {
1067
1175
  const s = sessions.latest(process.cwd()); // phiên gần nhất CỦA workspace này
@@ -1120,6 +1228,7 @@ NGUYÊN TẮC:
1120
1228
  }
1121
1229
  }
1122
1230
  exiting = true;
1231
+ if (usageRefreshTimer) clearInterval(usageRefreshTimer);
1123
1232
  tui.close();
1124
1233
  process.exit(0);
1125
1234
 
@@ -1140,7 +1249,9 @@ NGUYÊN TẮC:
1140
1249
  // Nhờ vậy người dùng LUÔN thấy đồng hồ + token đang chạy, kể cả khi treo chờ y/n.
1141
1250
  const tickMeta = () => {
1142
1251
  const elapsed = ((Date.now() - t0) / 1000).toFixed(0);
1143
- tui.setMeta(`${elapsed}s · ${tokenMeter.formatWithPct()}`);
1252
+ const quota = formatQuota(cachedUsage());
1253
+ const tail = quota ? ` · ${quota}` : '';
1254
+ tui.setMeta(`${elapsed}s · ${tokenMeter.formatWithPct()}${tail}`);
1144
1255
  };
1145
1256
  const tick = (label) => {
1146
1257
  tui.status(c.dim(`${label}…`));
@@ -1238,6 +1349,7 @@ NGUYÊN TẮC:
1238
1349
  );
1239
1350
  startSpin(t.thinking);
1240
1351
  try {
1352
+ const runData = state.workflowRun?.data || null;
1241
1353
  const results = await Promise.all(
1242
1354
  tasks.map((task, i) => {
1243
1355
  // Per-sub-agent model routing: task.model có thể là id model hoặc tên thân thiện.
@@ -1252,10 +1364,59 @@ NGUYÊN TẮC:
1252
1364
  } else
1253
1365
  modelTag = ` [model "${task.model}" không khớp — dùng ${state.model.name}]`;
1254
1366
  }
1255
- // [GỠ BUDGET 2026-06-06] Sub-agent chạy không giới hạn token.
1367
+ const taskBody = task?.task || task?.prompt || '';
1368
+ const taskCtx = task?.context || '';
1369
+ // Workflow journal: nếu đang trong run + task đã done lần trước → return
1370
+ // cached result, tiết kiệm token. Hash = crc32(task+ctx+model).
1371
+ if (runData) {
1372
+ const hash = hashWorkflowTask({ task: taskBody, context: taskCtx, model: subModel });
1373
+ const cached = lookupWorkflowTaskResult(runData, hash);
1374
+ if (cached !== null) {
1375
+ stopSpin();
1376
+ console.log(
1377
+ chalk.hex('#8b5cf6')(
1378
+ ` ⊘ sub-agent #${i + 1}${modelTag} skip — đã done trong run trước (cached)`
1379
+ )
1380
+ );
1381
+ startSpin(t.thinking);
1382
+ return Promise.resolve(
1383
+ `── sub-agent #${i + 1}${modelTag} (cached) ──\n${cached}`
1384
+ );
1385
+ }
1386
+ recordWorkflowTaskStart(runData, {
1387
+ hash,
1388
+ task: taskBody,
1389
+ context: taskCtx,
1390
+ model: subModel,
1391
+ });
1392
+ // [GỠ BUDGET 2026-06-06] Sub-agent chạy không giới hạn token.
1393
+ return runSubAgent({
1394
+ task: taskBody,
1395
+ context: taskCtx,
1396
+ depth: depth + 1,
1397
+ model: subModel,
1398
+ signal: abort.signal,
1399
+ tokenMeter,
1400
+ dispatchTool: (n, inp) => dispatchTool(n, inp, depth + 1),
1401
+ onLog: (msg) => {
1402
+ stopSpin();
1403
+ console.log(chalk.hex('#8b5cf6')(' ' + msg + modelTag));
1404
+ startSpin(t.thinking);
1405
+ },
1406
+ })
1407
+ .then((r) => {
1408
+ recordWorkflowTaskDone(runData, hash, r);
1409
+ return `── sub-agent #${i + 1}${modelTag} ──\n${r}`;
1410
+ })
1411
+ .catch((e) => {
1412
+ recordWorkflowTaskFailed(runData, hash, e);
1413
+ return `── sub-agent #${i + 1}${modelTag} (LỖI) ──\n${e?.message || String(e)}`;
1414
+ });
1415
+ }
1416
+ // Không có active workflow run → behavior cũ.
1256
1417
  return runSubAgent({
1257
- task: task?.task || task?.prompt || '',
1258
- context: task?.context || '',
1418
+ task: taskBody,
1419
+ context: taskCtx,
1259
1420
  depth: depth + 1,
1260
1421
  model: subModel,
1261
1422
  signal: abort.signal,
@@ -1335,15 +1496,18 @@ NGUYÊN TẮC:
1335
1496
  abort = null;
1336
1497
  tui.setBusy(false);
1337
1498
  // Auto-compact dựa trên context tokens thay vì chars.
1338
- // 80% context window (160k tokens) auto compact.
1339
- // 70% (140k tokens) → cảnh báo mạnh.
1340
- // 60% (120k tokens) → nhắc nhẹ.
1499
+ // Với CONTEXT_WINDOW = 2M tokens (xem src/tokens.js):
1500
+ // 75% (1.5M tokens) → auto compact
1501
+ // 60% (1.2M tokens) → cảnh báo mạnh
1502
+ // 40% (800k tokens) → nhắc nhẹ
1503
+ // Ngưỡng kéo xuống vì model context dài hiện tại để 80% mới compact thì
1504
+ // mỗi lượt cuối đã ăn 200k+ tokens — auto-compact sớm hơn giữ phiên mượt.
1341
1505
  try {
1342
1506
  const totalTokens = countMessages(state.history);
1343
1507
  const k = Math.round(totalTokens / 1000);
1344
1508
  const pct = Math.round((totalTokens / CONTEXT_WINDOW) * 100);
1345
- // Mốc 3 (≥80% — 160k tokens): TỰ ĐỘNG compact.
1346
- if (totalTokens >= CONTEXT_WINDOW * 0.8 && !state._autoCompacting) {
1509
+ // Mốc 3 (≥75% — 1.5M tokens): TỰ ĐỘNG compact.
1510
+ if (totalTokens >= CONTEXT_WINDOW * 0.75 && !state._autoCompacting) {
1347
1511
  state._autoCompacting = true;
1348
1512
  console.log(c.accent(` ⚡ ${t.autoCompactTrigger(k)} (${pct}% context)`));
1349
1513
  tui.setBusy(true, t.compactRunning);
@@ -1371,12 +1535,12 @@ NGUYÊN TẮC:
1371
1535
  } finally {
1372
1536
  state._autoCompacting = false;
1373
1537
  }
1374
- } else if (totalTokens >= CONTEXT_WINDOW * 0.7) {
1375
- // Mốc 2 (≥70% — 140k tokens): cảnh báo mạnh.
1538
+ } else if (totalTokens >= CONTEXT_WINDOW * 0.6) {
1539
+ // Mốc 2 (≥60% — 1.2M tokens): cảnh báo mạnh.
1376
1540
  console.log(c.err(` ⚠ ${t.veryLongSession(k)} (${pct}% context)`));
1377
1541
  state._longSessionWarned = true;
1378
- } else if (totalTokens >= CONTEXT_WINDOW * 0.6 && !state._longSessionWarned) {
1379
- // Mốc 1 (≥60% — 120k tokens): nhắc nhẹ một lần.
1542
+ } else if (totalTokens >= CONTEXT_WINDOW * 0.4 && !state._longSessionWarned) {
1543
+ // Mốc 1 (≥40% — 800k tokens): nhắc nhẹ một lần.
1380
1544
  console.log(c.dim(` ⓘ ${t.longSession(k)} (${pct}% context)`));
1381
1545
  state._longSessionWarned = true;
1382
1546
  }
@@ -1393,9 +1557,52 @@ NGUYÊN TẮC:
1393
1557
  const color = name === 'run_command' ? '#ef4444' : '#f59e0b';
1394
1558
  console.log('\n' + chalk.hex(color)(' ⚙ ' + name) + c.dim(' ' + desc));
1395
1559
 
1396
- if (name === 'write_file' && input.content) preview(input.content, input.path);
1397
- else if (name === 'edit_file')
1398
- preview(`- ${truncate(input.old_string)}\n+ ${truncate(input.new_string)}`, input.path);
1560
+ // Diff preview show user CHÍNH XÁC dòng nào bị xoá/thêm trước khi approve.
1561
+ // Tránh tin model mù: bắt được hallucinate nội dung sớm.
1562
+ if (name === 'edit_file' && typeof input.old_string === 'string' && typeof input.new_string === 'string') {
1563
+ try {
1564
+ const filePath = path.resolve(process.cwd(), input.path || '');
1565
+ let oldText = input.old_string;
1566
+ let newText = input.new_string;
1567
+ // Cố gắng đọc file gốc + locate old_string để show diff CỦA CẢ FILE
1568
+ // (vài dòng context xung quanh). Nếu old_string không match đúng (vd model
1569
+ // dùng escape khác), fallback về diff trực tiếp old_string vs new_string.
1570
+ if (fs.existsSync(filePath)) {
1571
+ const fileContent = fs.readFileSync(filePath, 'utf8');
1572
+ const idx = fileContent.indexOf(input.old_string);
1573
+ if (idx !== -1) {
1574
+ // Full file before + after thay đổi → diff sẽ render đúng hunk
1575
+ // có vài dòng context xung quanh sửa đổi (chuẩn unified diff).
1576
+ oldText = fileContent;
1577
+ if (input.replace_all) {
1578
+ newText = fileContent.split(input.old_string).join(input.new_string);
1579
+ } else {
1580
+ const before = fileContent.slice(0, idx);
1581
+ const after = fileContent.slice(idx + input.old_string.length);
1582
+ newText = before + input.new_string + after;
1583
+ }
1584
+ }
1585
+ }
1586
+ console.log(renderUnifiedDiff(oldText, newText, { label: input.path || '(unknown path)' }));
1587
+ } catch (e) {
1588
+ // Fallback nếu đọc file lỗi: ít nhất show old vs new raw.
1589
+ console.log(renderUnifiedDiff(input.old_string, input.new_string, { label: input.path || '(unknown path)' }));
1590
+ }
1591
+ } else if (name === 'write_file' && typeof input.content === 'string') {
1592
+ try {
1593
+ const filePath = path.resolve(process.cwd(), input.path || '');
1594
+ if (fs.existsSync(filePath)) {
1595
+ // File đã tồn tại → write_file là OVERWRITE → show diff old vs new.
1596
+ const oldContent = fs.readFileSync(filePath, 'utf8');
1597
+ console.log(renderUnifiedDiff(oldContent, input.content, { label: input.path + ' (OVERWRITE)', maxLines: 60 }));
1598
+ } else {
1599
+ // File mới → preview top 20 dòng + tổng số dòng.
1600
+ console.log(renderNewFilePreview(input.content, { label: input.path || '(unknown path)', maxLines: 20 }));
1601
+ }
1602
+ } catch {
1603
+ console.log(renderNewFilePreview(input.content, { label: input.path || '(unknown path)', maxLines: 20 }));
1604
+ }
1605
+ }
1399
1606
 
1400
1607
  if (DESTRUCTIVE.has(name) && !state.yolo && !state.autoApprove.has(name)) {
1401
1608
  const a = await askPermission(name);
@@ -1447,70 +1654,6 @@ NGUYÊN TẮC:
1447
1654
  }
1448
1655
  }
1449
1656
 
1450
- // Hỏi user có muốn cấp quyền folder ngoài workspace cho tool call hiện tại
1451
- // hay không. Trả về "y" | "n" | "a" (luôn). y = chỉ lần này; a = auto-approve
1452
- // mọi add-root trong phiên. Cùng style retry với askPermission (lạc → queue).
1453
- async function askAddRoot(root, targetPath) {
1454
- tui.setBusy(false);
1455
- console.log(c.tool(' ⏸ Cần cấp quyền folder: ') + c.accent(root));
1456
- console.log(c.dim(' (model muốn truy cập: ' + targetPath + ')'));
1457
- try {
1458
- while (true) {
1459
- const raw = await ask(
1460
- c.tool(' cho phép? ') +
1461
- c.dim('[y] thêm vào scope lần này / [a] luôn thêm / [n] từ chối › ')
1462
- );
1463
- if (raw == null) return 'n';
1464
- const a = raw.trim().toLowerCase();
1465
- if (a === '' || a === 'y' || a === 'yes' || a === 'có') return 'y';
1466
- if (a === 'n' || a === 'no' || a === 'không') return 'n';
1467
- if (a === 'a' || a === 'always' || a === 'luôn') return 'a';
1468
- if (raw.trim().length > 3) {
1469
- pending.push(raw);
1470
- console.log(c.dim(' ' + t.queued(pending.length, truncate(raw, 60))));
1471
- }
1472
- console.log(c.dim(' → gõ y / n / a'));
1473
- }
1474
- } finally {
1475
- tui.setBusy(true, t.thinking);
1476
- }
1477
- }
1478
-
1479
- // Đọc câu trả lời cho phép một cách CHẮC CHẮN. Chỉ chấp nhận y/n/a (hoặc
1480
- // Enter = đồng ý). Nếu nhận một dòng lạ & dài — gần như chắc là một tin nhắn
1481
- // bị paste/gõ nhầm vào đúng lúc prompt hiện ra (đây là thủ phạm làm hỏng &
1482
- // "tự tắt" trước đây) — thì KHÔNG coi là từ chối: xếp nó vào hàng đợi tin
1483
- // nhắn rồi HỎI LẠI. Nhờ vậy không thao tác nào bị quyết định bởi rác.
1484
- async function askPermission(name) {
1485
- // Tắt spinner "đang chạy" trong lúc chờ duyệt. Nếu để nguyên, thanh dưới hiện
1486
- // "đang chạy · Ctrl+C để dừng" → người dùng tưởng đang bận, không biết phải gõ
1487
- // y/n nên lượt TREO. Báo bằng 1 dòng cố định (vào scrollback, không bị vẽ đè)
1488
- // + bỏ spinner để prompt nổi bật. finally khôi phục trạng thái chạy.
1489
- tui.setBusy(false);
1490
- console.log(
1491
- c.tool(' ⏸ Cần quyền: ' + name) + c.dim(' — gõ y (đồng ý) / n (từ chối) / a (luôn cho phép)')
1492
- );
1493
- try {
1494
- while (true) {
1495
- const raw = await ask(
1496
- c.tool(' cho phép? ') + c.dim('[y] có / [n] không / [a] luôn ' + name + ' › ')
1497
- );
1498
- if (raw == null) return 'n'; // stdin đóng thật
1499
- const a = raw.trim().toLowerCase();
1500
- if (a === '' || a === 'y' || a === 'yes' || a === 'có') return 'y';
1501
- if (a === 'n' || a === 'no' || a === 'không') return 'n';
1502
- if (a === 'a' || a === 'always' || a === 'luôn') return 'a';
1503
- if (raw.trim().length > 3) {
1504
- pending.push(raw); // tin nhắn lạc → xếp hàng, gửi sau khi xong lượt
1505
- console.log(c.dim(' ' + t.queued(pending.length, truncate(raw, 60))));
1506
- }
1507
- console.log(c.dim(' ' + t.permRetry));
1508
- }
1509
- } finally {
1510
- tui.setBusy(true, t.thinking); // khôi phục "đang chạy" cho phần còn lại của lượt
1511
- }
1512
- }
1513
-
1514
1657
  // ── slash commands ─────────────────────────────────────────────────────
1515
1658
  async function command(input) {
1516
1659
  const [cmd, ...rest] = input.slice(1).split(/\s+/);
@@ -1638,6 +1781,7 @@ NGUYÊN TẮC:
1638
1781
  break;
1639
1782
  case 'logout':
1640
1783
  config.clearKey();
1784
+ resetUsageCache();
1641
1785
  console.log(c.ok(' ' + t.loggedOut));
1642
1786
  break;
1643
1787
  case 'usage':
@@ -1770,6 +1914,7 @@ NGUYÊN TẮC:
1770
1914
  function doLogin(key) {
1771
1915
  if (!key) return console.log(c.err(' ' + t.needKeyArg));
1772
1916
  config.setKey(key);
1917
+ resetUsageCache();
1773
1918
  console.log(c.ok(' ✓ ') + c.dim(t.loginSaved(config.path)));
1774
1919
  showUsage().catch(() => {});
1775
1920
  }
@@ -1778,7 +1923,7 @@ NGUYÊN TẮC:
1778
1923
  if (!config.apiKey) return console.log(c.tool(' ' + t.notLoggedIn));
1779
1924
  tui.status(c.dim(' ...'));
1780
1925
  try {
1781
- const u = await usage();
1926
+ const u = await usage({ force: true });
1782
1927
  tui.status(null);
1783
1928
  if (!u.ok) return printError(new ApiError(t.errInvalidKey, { code: u.error }));
1784
1929
  printUsage(u);
@@ -1857,13 +2002,6 @@ NGUYÊN TẮC:
1857
2002
  }
1858
2003
 
1859
2004
  // ── presentation helpers ───────────────────────────────────────────────────
1860
- function fmtK(n) {
1861
- return n >= 1000000
1862
- ? (n / 1000000).toFixed(1) + 'M'
1863
- : n >= 1000
1864
- ? (n / 1000).toFixed(1) + 'k'
1865
- : String(n);
1866
- }
1867
2005
  function printAnswer(text, name, color) {
1868
2006
  if (!text?.trim()) return;
1869
2007
  console.log('\n' + chalk.hex(color).bold(' ● ' + name));
@@ -1875,26 +2013,61 @@ function printAnswer(text, name, color) {
1875
2013
  );
1876
2014
  }
1877
2015
 
1878
- // In câu trả lời theo dòng token thời gian thực. model emit lời + (tuỳ chọn)
1879
- // MỘT khối ```tool cuối, ta giấu mọi thứ từ ```tool trở đi (người dùng chỉ
1880
- // thấy phần lời + hoạt động công cụ riêng). Giữ lại đuôi vài ký tự để không in
1881
- // nửa vời "```to" trước khi kịp nhận ra đó fence.
2016
+ // Stream printer với inline markdown rendering. Chiến lược:
2017
+ // - Tích đầy đủ MỘT dòng (\n) rồi mới render & write vậy `**bold**`, backtick,
2018
+ // heading `## foo` không bị cắt giữa chừng giữa các token.
2019
+ // - Trên dòng đang dở (chưa thấy \n), giữ trong buffer; cuối cùng `flush()` xử lý.
2020
+ // - Block code fence (```lang ... ```) bypass renderer: in nguyên xi giữa cặp ```.
2021
+ // - ```tool trở đi: nuốt sạch (sẽ render riêng qua tool dispatcher).
2022
+ function renderStreamLine(line, inCodeFence) {
2023
+ if (inCodeFence) return line; // code fence body: in raw, không parse markdown.
2024
+ const heading = renderHeadingLine(line);
2025
+ if (heading !== null) return heading;
2026
+ return renderInline(renderBulletPrefix(line));
2027
+ }
1882
2028
  function makeStreamPrinter(name, color) {
1883
- let buf = '';
1884
- let printed = 0;
2029
+ let buf = ''; // toàn bộ delta đã nhận (chưa cắt)
2030
+ let printed = 0; // offset đã in trong buf
1885
2031
  let suppress = false;
1886
2032
  let started = false;
1887
2033
  let header = false;
1888
- const HOLD = 8;
1889
- const write = (s) => {
2034
+ let inCodeFence = false;
2035
+ const HOLD = 8; // giữ đuôi 8 char để phát hiện sớm "```tool"
2036
+ const emit = (s) => {
1890
2037
  if (!s) return;
1891
2038
  if (!header) {
1892
2039
  process.stdout.write('\n' + chalk.hex(color).bold(' ● ' + name) + '\n ');
1893
2040
  header = true;
1894
2041
  }
2042
+ // s có thể chứa \n từ render line + leading newline gốc. Thay \n → \n để indent.
1895
2043
  process.stdout.write(s.replace(/\n/g, '\n '));
1896
2044
  started = true;
1897
2045
  };
2046
+ // Render & emit từ printed → end. Chỉ flush các dòng đã HOÀN CHỈNH (có \n).
2047
+ // Dòng cuối chưa có \n: chừa lại trong buf cho lần push tiếp theo.
2048
+ const flushCompleteLines = (end) => {
2049
+ const slice = buf.slice(printed, end);
2050
+ if (!slice) return;
2051
+ const lastNl = slice.lastIndexOf('\n');
2052
+ if (lastNl === -1) return; // chưa có dòng nào hoàn chỉnh trong khoảng này
2053
+ const complete = slice.slice(0, lastNl + 1); // bao gồm \n cuối
2054
+ printed += lastNl + 1;
2055
+ // Render từng dòng (split giữ \n)
2056
+ const lines = complete.split('\n');
2057
+ // split với chuỗi kết thúc \n → mảng cuối là '' — bỏ qua
2058
+ const rendered = lines.map((ln, i) => {
2059
+ if (i === lines.length - 1 && ln === '') return '';
2060
+ // Toggle code fence khi gặp ``` ở đầu dòng (allow indent).
2061
+ const fenceMatch = ln.match(/^\s*```/);
2062
+ if (fenceMatch) {
2063
+ const out = renderStreamLine(ln, inCodeFence);
2064
+ inCodeFence = !inCodeFence;
2065
+ return out;
2066
+ }
2067
+ return renderStreamLine(ln, inCodeFence);
2068
+ }).join('\n');
2069
+ emit(rendered);
2070
+ };
1898
2071
  return {
1899
2072
  get started() {
1900
2073
  return started;
@@ -1907,19 +2080,49 @@ function makeStreamPrinter(name, color) {
1907
2080
  if (suppress) return;
1908
2081
  const f = buf.indexOf('```tool');
1909
2082
  if (f !== -1) {
1910
- write(buf.slice(printed, f));
2083
+ // Flush mọi thứ TRƯỚC ```tool (full lines + phần dở của dòng cuối cũng in luôn
2084
+ // vì sắp suppress, không cần chừa buffer nữa).
2085
+ const beforeTool = buf.slice(printed, f);
2086
+ if (beforeTool) {
2087
+ // Render từng dòng kể cả dòng dở cuối
2088
+ const parts = beforeTool.split('\n');
2089
+ const rendered = parts.map((ln) => {
2090
+ const fenceMatch = ln.match(/^\s*```/);
2091
+ if (fenceMatch) {
2092
+ const out = renderStreamLine(ln, inCodeFence);
2093
+ inCodeFence = !inCodeFence;
2094
+ return out;
2095
+ }
2096
+ return renderStreamLine(ln, inCodeFence);
2097
+ }).join('\n');
2098
+ emit(rendered);
2099
+ }
1911
2100
  printed = buf.length;
1912
2101
  suppress = true;
1913
2102
  return;
1914
2103
  }
2104
+ // Chỉ flush các dòng đã hoàn chỉnh; chừa đuôi HOLD char để phát hiện ```tool.
1915
2105
  const safeEnd = Math.max(printed, buf.length - HOLD);
1916
- if (safeEnd > printed) {
1917
- write(buf.slice(printed, safeEnd));
1918
- printed = safeEnd;
1919
- }
2106
+ if (safeEnd > printed) flushCompleteLines(safeEnd);
1920
2107
  },
1921
2108
  flush() {
1922
- if (!suppress && printed < buf.length) write(buf.slice(printed));
2109
+ if (!suppress && printed < buf.length) {
2110
+ // Render phần còn lại (kể cả dòng cuối chưa có \n).
2111
+ const tail = buf.slice(printed);
2112
+ const parts = tail.split('\n');
2113
+ const rendered = parts.map((ln, i) => {
2114
+ // Toggle fence
2115
+ const fenceMatch = ln.match(/^\s*```/);
2116
+ if (fenceMatch) {
2117
+ const out = renderStreamLine(ln, inCodeFence);
2118
+ inCodeFence = !inCodeFence;
2119
+ return out;
2120
+ }
2121
+ return renderStreamLine(ln, inCodeFence);
2122
+ }).join('\n');
2123
+ emit(rendered);
2124
+ printed = buf.length;
2125
+ }
1923
2126
  if (started) process.stdout.write('\n');
1924
2127
  },
1925
2128
  };
@@ -2027,37 +2230,4 @@ function listModels() {
2027
2230
  console.log('\n' + lines.join('\n') + c.dim('\n\n ' + t.modelListHint) + '\n');
2028
2231
  }
2029
2232
 
2030
- const shortCwd = () => {
2031
- const p = process.cwd();
2032
- return p.length > 48 ? '…' + p.slice(-47) : p;
2033
- };
2034
- const shortPath = (p = '') => (p.length > 30 ? '…' + p.slice(-29) : p);
2035
- const relTime = (ts) => {
2036
- const m = Math.round((Date.now() - ts) / 60000);
2037
- if (m < 1) return 'vừa xong';
2038
- if (m < 60) return m + ' phút trước';
2039
- const h = Math.round(m / 60);
2040
- if (h < 24) return h + ' giờ trước';
2041
- return Math.round(h / 24) + ' ngày trước';
2042
- };
2043
- const firstLine = (s) => (s.split('\n')[0] || '').slice(0, 100);
2044
- const truncate = (s = '', n = 120) => (s.length > n ? s.slice(0, n) + '…' : s).replace(/\n/g, '⏎');
2045
- const fmtTime = (iso) => {
2046
- try {
2047
- return new Date(iso).toLocaleString('vi-VN');
2048
- } catch {
2049
- return iso;
2050
- }
2051
- };
2052
2233
 
2053
- function preview(content, label) {
2054
- const lines = content.split('\n').slice(0, 12);
2055
- const more = content.split('\n').length - lines.length;
2056
- console.log(
2057
- c.dim(' ┌─ ' + (label || '')) +
2058
- '\n' +
2059
- lines.map((l) => c.dim(' │ ') + l.slice(0, 110)).join('\n') +
2060
- (more > 0 ? c.dim(`\n │ … +${more} dòng nữa`) : '') +
2061
- c.dim('\n └─')
2062
- );
2063
- }