@noobdemon/noob-cli 1.7.2 → 1.7.4

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.2",
3
+ "version": "1.7.4",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/src/agent.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import os from "node:os";
2
2
  import { stream } from "./api.js";
3
3
  import { loadMemory } from "./memory.js";
4
+ import { listRoots } from "./tools.js";
4
5
  import { t } from "./i18n.js";
5
6
  import { countTokens } from "./tokens.js";
6
7
 
@@ -92,6 +93,19 @@ function runtimeContext() {
92
93
  `- Shell for run_command: ${isWin ? "Windows PowerShell (powershell.exe)" : "bash"}`,
93
94
  `- Current working directory: ${process.cwd()}`,
94
95
  ];
96
+ // Extra roots cấp qua /add-dir: model PHẢI biết để chủ động dùng (đọc/list/grep).
97
+ // Không liệt kê ở đây → model không 'thấy' thư mục đó tồn tại dù tools layer
98
+ // đã accept path.
99
+ try {
100
+ const roots = listRoots();
101
+ const extras = roots.slice(1); // [0] là cwd
102
+ if (extras.length) {
103
+ lines.push(
104
+ `- Extra roots cấp quyền qua /add-dir (tool path nằm trong các thư mục này cũng hợp lệ — dùng path tuyệt đối khi gọi tool):`,
105
+ );
106
+ for (const r of extras) lines.push(` • ${r}`);
107
+ }
108
+ } catch {}
95
109
  if (isWin) {
96
110
  lines.push(
97
111
  "- IMPORTANT: run_command runs in PowerShell on Windows — do NOT use Unix tools.",
@@ -137,12 +151,13 @@ function compact(history, budget) {
137
151
  // lượt cũ thành một message system gọn (giữ quyết định, file đã sửa, lý do,
138
152
  // việc dở) rồi thay phần đầu history bằng tóm tắt đó. Mutates `history` in place.
139
153
  // Trả về true nếu có tóm tắt (để caller persist phiên ngay).
140
- export async function maybeSummarize(history, { model, signal }) {
154
+ export async function maybeSummarize(history, { model, signal, force = false } = {}) {
141
155
  if (!history?.length) return false;
142
156
  const totalChars = history.reduce((s, m) => s + (m.content?.length || 0), 0);
143
- if (totalChars < SUMMARIZE_THRESHOLD_CHARS) return false;
157
+ if (!force && totalChars < SUMMARIZE_THRESHOLD_CHARS) return false;
144
158
  // Giữ 8 message cuối nguyên vẹn; tóm tắt phần trước.
145
- const keepTail = 8;
159
+ // Khi force (gọi từ /compact), giữ ít tail hơn để tóm tắt mạnh hơn.
160
+ const keepTail = force ? 4 : 8;
146
161
  if (history.length <= keepTail + 2) return false;
147
162
  // Nếu lượt đầu đã là summary (role=system, name=summary) → tóm tắt thêm.
148
163
  const head = history.slice(0, history.length - keepTail);
package/src/i18n.js CHANGED
@@ -64,7 +64,9 @@ export const t = {
64
64
  cmdKarpathy: "/karpathy [path] rà soát code theo 4 nguyên tắc Karpathy (/kc)",
65
65
  cmdUltra: "/ultra <mục tiêu> tự hành: noob tự nghĩ & tự làm nhiệm vụ tới khi xong (/u)",
66
66
  cmdLearn: "/learn [ghi chú] chưng cất bài học của phiên vào noob.md",
67
+ cmdCompact: "/compact tóm tắt phiên ngay để gọn ngữ cảnh (giữ trí nhớ dài hạn)",
67
68
  cmdMemory: "/memory xem bộ nhớ noob.md (/mem)",
69
+ cmdAddDir: "/add-dir <path> thêm thư mục ngoài cwd vào phạm vi tool (không arg = liệt kê)",
68
70
  cmdClear: "/clear /new xoá ngữ cảnh (mở phiên mới, phiên cũ vẫn resume được)",
69
71
  cmdResume: "/resume [id] tiếp tục phiên cũ (không id = chọn từ danh sách)",
70
72
  cmdSessions: "/sessions liệt kê các phiên đã lưu",
@@ -111,6 +113,15 @@ export const t = {
111
113
  ultraNeedGoal: "Cần mục tiêu. Dùng: /ultra <mô tả mục tiêu>",
112
114
  ultraQuest: (n) => `tự nghĩ nhiệm vụ kế tiếp (vòng ${n})…`,
113
115
  learning: "đang chưng cất bài học vào noob.md…",
116
+ compactRunning: "đang tóm tắt phiên để gọn ngữ cảnh…",
117
+ compactEmpty: "Phiên còn trống — không có gì để tóm tắt.",
118
+ compactSkipped: "Phiên còn ngắn hoặc tóm tắt thất bại — bỏ qua.",
119
+ compactDone: (bMsgs, aMsgs, bK, aK, pct) => `Đã tóm tắt: ${bMsgs} → ${aMsgs} tin · ${bK}k → ${aK}k chars (giảm ${pct}%).`,
120
+ longSession: (k) => `Phiên dài (${k}k chars). Cân nhắc /compact để gọn ngữ cảnh (giữ trí nhớ) hoặc /clear để phiên mới hoàn toàn.`,
121
+ veryLongSession: (k) => `⚠ Phiên RẤT dài (${k}k chars) — model có thể chậm/lú. Khuyến nghị /compact ngay, hoặc /clear nếu task đã xong.`,
122
+ autoCompactTrigger: (k) => `Phiên đã đạt ${k}k chars — tự động tóm tắt để giữ model chạy mượt…`,
123
+ autoCompactDone: (bK, aK, pct) => `✓ Auto-compact: ${bK}k → ${aK}k chars (giảm ${pct}%). Trí nhớ dài hạn đã giữ lại trong session_summary.`,
124
+ autoCompactFail: "Auto-compact thất bại — bạn nên /clear hoặc /compact thủ công.",
114
125
  initRunning: "đang quét dự án & soạn noob.md…",
115
126
  initOverwriteWarn: (p) => `⚠ Đã có noob.md tại ${p}. /init sẽ ghi đè nội dung hiện tại.`,
116
127
  initOverwriteConfirm: "Ghi đè? gõ 'y' để xác nhận, phím khác để huỷ › ",
package/src/repl.js CHANGED
@@ -3,11 +3,11 @@ import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import chalk from "chalk";
5
5
  import { createTui } from "./tui.js";
6
- import { runAgent } from "./agent.js";
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
9
  import { stream, usage, ApiError } from "./api.js";
10
- import { runTool, describe, DESTRUCTIVE } from "./tools.js";
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";
@@ -43,6 +43,7 @@ const SLASH = [
43
43
  { name: "/continue", desc: "tiếp tục phiên gần nhất" },
44
44
  { name: "/sessions", desc: "liệt kê phiên đã lưu" },
45
45
  { name: "/cwd", desc: "thư mục hiện tại" },
46
+ { name: "/add-dir", desc: "thêm thư mục ngoài cwd vào phạm vi" },
46
47
  { name: "/status", desc: "trạng thái" },
47
48
  { name: "/version", desc: "phiên bản" },
48
49
  { name: "/exit", desc: "thoát" },
@@ -132,6 +133,9 @@ export async function startRepl(opts = {}) {
132
133
  yolo: !!opts.yolo || config.yoloDefault, // cờ --yolo HOẶC mặc định đã lưu (/auto-yolo)
133
134
  ultra: false, // chế độ tự hành (self-quest) đang chạy?
134
135
  agentMode: false, // /agent on → cho phép spawn_agent / spawn_agents
136
+ extraRoots: [], // thư mục ngoài cwd được /add-dir cấp quyền (UX display only;
137
+ // source of truth là extraRoots trong src/tools.js)
138
+ _longSessionWarned: false, // đã in cảnh báo phiên dài chưa (reset khi /clear)
135
139
  };
136
140
  const tokenMeter = new TokenMeter();
137
141
 
@@ -485,6 +489,35 @@ NGUYÊN TẮC:
485
489
  persist();
486
490
  }
487
491
 
492
+ // /compact — chủ động tóm tắt phiên ngay để gọn ngữ cảnh, giữ trí nhớ dài hạn.
493
+ // Khác /clear (xoá sạch) và khác auto-summarize (chỉ chạy khi vượt ngưỡng).
494
+ async function runCompact() {
495
+ if (!config.apiKey) return console.log(c.tool(" " + t.notLoggedIn));
496
+ if (!state.history?.length) return console.log(c.dim(" " + t.compactEmpty));
497
+ const beforeChars = state.history.reduce((a, m) => a + (typeof m.content === "string" ? m.content.length : 0), 0);
498
+ const beforeMsgs = state.history.length;
499
+ console.log(c.tool(" 🗜 " + t.compactRunning));
500
+ tui.setBusy(true, t.compactRunning);
501
+ try {
502
+ const ok = await maybeSummarize(state.history, { model: state.model, force: true });
503
+ tui.setBusy(false);
504
+ if (!ok) {
505
+ console.log(c.dim(" " + t.compactSkipped));
506
+ return;
507
+ }
508
+ const afterChars = state.history.reduce((a, m) => a + (typeof m.content === "string" ? m.content.length : 0), 0);
509
+ const afterMsgs = state.history.length;
510
+ const saved = Math.max(0, beforeChars - afterChars);
511
+ const pct = beforeChars > 0 ? Math.round((saved / beforeChars) * 100) : 0;
512
+ console.log(c.ok(" ✓ " + t.compactDone(beforeMsgs, afterMsgs, Math.round(beforeChars / 1000), Math.round(afterChars / 1000), pct)));
513
+ state._longSessionWarned = false; // reset để có thể cảnh báo lại nếu lại phình
514
+ persist();
515
+ } catch (err) {
516
+ tui.setBusy(false);
517
+ printError(err);
518
+ }
519
+ }
520
+
488
521
  function showMemory() {
489
522
  const mem = loadMemory();
490
523
  if (!mem) return console.log(c.dim(" " + t.memoryEmpty(memoryPath())));
@@ -601,11 +634,17 @@ NGUYÊN TẮC:
601
634
  // spinner: status hiển thị qua tui (thanh dưới), tui tự animate khung xoay
602
635
  const t0 = Date.now();
603
636
  let timer = null;
604
- const tick = (label) => {
637
+ // Tách label (status) meta (elapsed+tokens):
638
+ // - status có thể bị reset (vd. lúc xin permission, lúc đang in answer)
639
+ // - meta đi qua setMeta, vẫn hiện trong busy bar nền dù status null
640
+ // Nhờ vậy người dùng LUÔN thấy đồng hồ + token đang chạy, kể cả khi treo chờ y/n.
641
+ const tickMeta = () => {
605
642
  const elapsed = ((Date.now() - t0) / 1000).toFixed(0);
606
- // Chèn token usage realtime (↑input ↓output) ngay cạnh spinner để người
607
- // dùng thấy được số token đang cộng dồn trong khi model stream.
608
- tui.status(c.dim(`${label}… ${elapsed}s · ${tokenMeter.format()}`));
643
+ tui.setMeta(`${elapsed}s · ${tokenMeter.format()}`);
644
+ };
645
+ const tick = (label) => {
646
+ tui.status(c.dim(`${label}…`));
647
+ tickMeta();
609
648
  };
610
649
  const stopSpin = () => {
611
650
  if (timer) {
@@ -613,9 +652,10 @@ NGUYÊN TẮC:
613
652
  timer = null;
614
653
  }
615
654
  tui.status(null);
655
+ // KHÔNG reset meta ở đây — để token+elapsed vẫn hiện qua setBusy/busyMeta
656
+ // cho tới khi setBusy(false) ở finally tự dọn.
616
657
  };
617
658
  const startSpin = (label) => {
618
- // (tui hiện status khi tick gọi)
619
659
  if (!timer) timer = setInterval(() => tick(label), 200);
620
660
  };
621
661
 
@@ -630,8 +670,10 @@ NGUYÊN TẮC:
630
670
  message: text,
631
671
  signal: abort.signal,
632
672
  onStatus: (s) => {
633
- // Kèm token meter để nhánh merge/search cũng thấy ↑input ↓output realtime.
634
- if (!printer.started) tui.status(c.dim(` ${s} · ${tokenMeter.format()}`));
673
+ // Status label đi qua tui.status; token+elapsed đi qua setMeta (vẫn hiện
674
+ // printer đã bắt đầu vì busyMeta nằm trên busy bar nền).
675
+ if (!printer.started) tui.status(c.dim(` ${s}`));
676
+ tickMeta();
635
677
  },
636
678
  onDelta: (d) => {
637
679
  stopSpin();
@@ -735,6 +777,55 @@ NGUYÊN TẮC:
735
777
  } finally {
736
778
  abort = null;
737
779
  tui.setBusy(false);
780
+ // Cảnh báo phiên dài: in một lần khi tổng chars vượt ~2× ngưỡng summarize
781
+ // (60k trong agent.js). Tự maybeSummarize đã chạy bên trong, nhưng người
782
+ // dùng có thể muốn /clear chủ động cho gọn ngữ cảnh + tốc độ.
783
+ try {
784
+ const totalChars = state.history.reduce(
785
+ (a, m) => a + (typeof m.content === "string" ? m.content.length : JSON.stringify(m.content || "").length),
786
+ 0,
787
+ );
788
+ const k = Math.round(totalChars / 1000);
789
+ // Mốc 3 (240k+): TỰ ĐỘNG compact — không hỏi, không chờ user. Mục tiêu là
790
+ // giữ model chạy mượt khi user mải làm việc, không để phiên phình mãi.
791
+ // Dùng cờ _autoCompacting chống re-entrant (nếu compact lâu, lượt sau
792
+ // tới trước khi xong thì bỏ qua).
793
+ if (totalChars > 240000 && !state._autoCompacting) {
794
+ state._autoCompacting = true;
795
+ console.log(c.accent(" ⚡ " + t.autoCompactTrigger(k)));
796
+ tui.setBusy(true, t.compactRunning);
797
+ try {
798
+ const ok = await maybeSummarize(state.history, { model: state.model, force: true });
799
+ tui.setBusy(false);
800
+ if (ok) {
801
+ const afterChars = state.history.reduce(
802
+ (a, m) => a + (typeof m.content === "string" ? m.content.length : 0),
803
+ 0,
804
+ );
805
+ const aK = Math.round(afterChars / 1000);
806
+ const pct = totalChars > 0 ? Math.round(((totalChars - afterChars) / totalChars) * 100) : 0;
807
+ console.log(c.ok(" " + t.autoCompactDone(k, aK, pct)));
808
+ state._longSessionWarned = false;
809
+ persist();
810
+ } else {
811
+ console.log(c.err(" " + t.autoCompactFail));
812
+ }
813
+ } catch (e) {
814
+ tui.setBusy(false);
815
+ console.log(c.err(" " + t.autoCompactFail));
816
+ } finally {
817
+ state._autoCompacting = false;
818
+ }
819
+ } else if (totalChars > 200000) {
820
+ // Mốc 2 (200k–240k): cảnh báo mạnh, in lại mỗi lượt.
821
+ console.log(c.err(" " + t.veryLongSession(k)));
822
+ state._longSessionWarned = true;
823
+ } else if (totalChars > 120000 && !state._longSessionWarned) {
824
+ // Mốc 1 (120k+): nhắc nhẹ một lần.
825
+ console.log(c.dim(" ⓘ " + t.longSession(k)));
826
+ state._longSessionWarned = true;
827
+ }
828
+ } catch {}
738
829
  }
739
830
  }
740
831
 
@@ -860,6 +951,9 @@ NGUYÊN TẮC:
860
951
  case "learn":
861
952
  await runLearn(arg);
862
953
  break;
954
+ case "compact":
955
+ await runCompact();
956
+ break;
863
957
  case "memory":
864
958
  case "mem":
865
959
  showMemory();
@@ -881,6 +975,7 @@ NGUYÊN TẮC:
881
975
  case "new":
882
976
  persist(); // giữ lại phiên cũ trên đĩa
883
977
  state.history = [];
978
+ state._longSessionWarned = false; // reset cờ cảnh báo phiên dài
884
979
  startFresh(); // phiên mới (phiên cũ vẫn resume được)
885
980
  if (!tui.tty) console.clear();
886
981
  banner();
@@ -908,6 +1003,28 @@ NGUYÊN TẮC:
908
1003
  case "cwd":
909
1004
  console.log(c.dim(" " + process.cwd()));
910
1005
  break;
1006
+ case "adddir":
1007
+ case "add-dir": {
1008
+ if (!arg) {
1009
+ // Không arg → liệt kê roots hiện tại (cwd + các thư mục đã /add-dir).
1010
+ const roots = listRoots();
1011
+ console.log(c.dim(" Phạm vi truy cập:"));
1012
+ for (const r of roots) {
1013
+ const isCwd = r === process.cwd();
1014
+ console.log(" " + (isCwd ? c.accent("• ") : c.ok("+ ")) + r + (isCwd ? c.dim(" (cwd)") : ""));
1015
+ }
1016
+ console.log(c.dim(" Dùng: /add-dir <đường-dẫn>"));
1017
+ break;
1018
+ }
1019
+ try {
1020
+ const full = addRoot(path.resolve(process.cwd(), arg));
1021
+ if (!state.extraRoots.includes(full)) state.extraRoots.push(full);
1022
+ console.log(c.ok(" ✓ ") + c.dim("đã thêm vào phạm vi: ") + full);
1023
+ } catch (e) {
1024
+ console.log(c.err(" ✗ ") + (e?.message || String(e)));
1025
+ }
1026
+ break;
1027
+ }
911
1028
  case "status":
912
1029
  printStatus(state);
913
1030
  break;
@@ -978,8 +1095,16 @@ NGUYÊN TẮC:
978
1095
  s.mode === "merge" ? c.tool("Merge AI") : s.mode === "search" ? c.accent("Tìm web") : modelBadge(s.model);
979
1096
  const key = config.apiKey ? c.ok(" 🔑") : c.err(" 🔒");
980
1097
  const yolo = s.yolo ? c.err(" ⚡ yolo: BẬT") : c.dim(" yolo: tắt");
1098
+ // Size phiên — màu đổi theo mức: dim < 60k, tool 60-120k, accent 120-200k, err > 200k.
1099
+ const totalChars = (s.history || []).reduce(
1100
+ (a, m) => a + (typeof m.content === "string" ? m.content.length : 0),
1101
+ 0,
1102
+ );
1103
+ const k = Math.round(totalChars / 1000);
1104
+ const sizeColor = totalChars > 200000 ? c.err : totalChars > 120000 ? c.accent : totalChars > 60000 ? c.tool : c.dim;
1105
+ const size = sizeColor(` ctx: ${k}k`);
981
1106
  console.log(
982
- " " + mode + key + yolo + c.dim(" v" + CURRENT) + c.dim(" thư mục: " + shortCwd()),
1107
+ " " + mode + key + yolo + size + c.dim(" v" + CURRENT) + c.dim(" thư mục: " + shortCwd()),
983
1108
  );
984
1109
  }
985
1110
  }
@@ -1093,7 +1218,9 @@ function printHelp() {
1093
1218
  " " + t.cmdKarpathy,
1094
1219
  " " + t.cmdUltra,
1095
1220
  " " + t.cmdLearn,
1221
+ " " + t.cmdCompact,
1096
1222
  " " + t.cmdMemory,
1223
+ " " + t.cmdAddDir,
1097
1224
  " " + t.cmdLogin,
1098
1225
  " " + t.cmdLogout,
1099
1226
  " " + t.cmdUsage,
package/src/tools.js CHANGED
@@ -5,8 +5,65 @@ import { spawn } from "node:child_process";
5
5
 
6
6
  const MAX_OUT = 30000; // hard cap on any tool result fed back to the model
7
7
  const cwd = () => process.cwd();
8
- const abs = (p) => path.resolve(cwd(), p);
8
+
9
+ // Các thư mục ngoài cwd được user cấp quyền qua /add-dir. Path tool sẽ chấp
10
+ // nhận nếu nó nằm trong cwd HOẶC trong một extra root. Source of truth ở đây.
11
+ const extraRoots = new Set();
12
+ export function addRoot(p) {
13
+ if (!p) throw new Error("thiếu path");
14
+ const full = path.resolve(p);
15
+ let st;
16
+ try { st = fssync.statSync(full); } catch { throw new Error("không tồn tại: " + p); }
17
+ if (!st.isDirectory()) throw new Error("không phải thư mục: " + p);
18
+ extraRoots.add(full);
19
+ return full;
20
+ }
21
+ export function listRoots() {
22
+ return [cwd(), ...extraRoots];
23
+ }
24
+ function within(root, full) {
25
+ if (full === root) return true;
26
+ const rel = path.relative(root, full);
27
+ return !!rel && !rel.startsWith("..") && !path.isAbsolute(rel);
28
+ }
29
+
30
+ const abs = (p) => {
31
+ if (!p) return cwd();
32
+ // Path tuyệt đối: chấp nhận nếu nằm trong cwd hoặc một extra root, không thì
33
+ // ném lỗi rõ ràng (tools dưới sẽ propagate cho LLM).
34
+ if (path.isAbsolute(p)) {
35
+ const full = path.resolve(p);
36
+ if (within(cwd(), full)) return full;
37
+ for (const r of extraRoots) if (within(r, full)) return full;
38
+ throw new Error("path nằm ngoài phạm vi (cwd + /add-dir): " + p);
39
+ }
40
+ // Tương đối: ưu tiên cwd; nếu thoát cwd thì thử từng extra root.
41
+ const full = path.resolve(cwd(), p);
42
+ if (within(cwd(), full)) return full;
43
+ for (const r of extraRoots) {
44
+ const fr = path.resolve(r, p);
45
+ if (within(r, fr)) return fr;
46
+ }
47
+ throw new Error("path nằm ngoài phạm vi (cwd + /add-dir): " + p);
48
+ };
9
49
  const rel = (p) => path.relative(cwd(), p) || ".";
50
+ // Tên rút gọn để hiển thị: nếu path thuộc cwd → relative cwd; nếu thuộc một
51
+ // extra root → "<rootName>/<rel>" để user phân biệt được; còn lại fallback path tuyệt đối.
52
+ function displayPath(full) {
53
+ if (within(cwd(), full)) return path.relative(cwd(), full) || ".";
54
+ for (const r of extraRoots) {
55
+ if (within(r, full)) {
56
+ const sub = path.relative(r, full);
57
+ return sub ? path.basename(r) + path.sep + sub : path.basename(r);
58
+ }
59
+ }
60
+ return full;
61
+ }
62
+ function relFrom(root, full) {
63
+ return path.relative(root, full) || ".";
64
+ }
65
+ // Thư mục bỏ qua khi walk (glob/grep). node_modules + các thư mục build/cache phổ biến.
66
+ const SKIP_DIRS = new Set(["node_modules", ".next", "dist", "build", ".venv", "venv", "__pycache__", ".cache", ".turbo", ".parcel-cache", "target"]);
10
67
 
11
68
  function clip(s) {
12
69
  if (s.length <= MAX_OUT) return s;
@@ -119,30 +176,35 @@ export const TOOLS = {
119
176
  async glob({ pattern }) {
120
177
  const hits = [];
121
178
  const rx = globToRegExp(pattern);
122
- (function walk(dir) {
123
- let ents;
124
- try {
125
- ents = fssync.readdirSync(dir, { withFileTypes: true });
126
- } catch {
127
- return;
128
- }
129
- for (const e of ents) {
130
- if (e.name === "node_modules" || e.name.startsWith(".git")) continue;
131
- const full = path.join(dir, e.name);
132
- if (e.isDirectory()) walk(full);
133
- else if (rx.test(rel(full).split(path.sep).join("/"))) hits.push(rel(full));
134
- if (hits.length > 500) return;
135
- }
136
- })(cwd());
179
+ const roots = listRoots();
180
+ for (const root of roots) {
181
+ (function walk(dir) {
182
+ let ents;
183
+ try {
184
+ ents = fssync.readdirSync(dir, { withFileTypes: true });
185
+ } catch {
186
+ return;
187
+ }
188
+ for (const e of ents) {
189
+ if (SKIP_DIRS.has(e.name) || e.name.startsWith(".git")) continue;
190
+ const full = path.join(dir, e.name);
191
+ if (e.isDirectory()) walk(full);
192
+ else if (rx.test(relFrom(root, full).split(path.sep).join("/"))) hits.push(displayPath(full));
193
+ if (hits.length > 500) return;
194
+ }
195
+ })(root);
196
+ if (hits.length > 500) break;
197
+ }
137
198
  return hits.length ? clip(hits.join("\n")) : "No files matched.";
138
199
  },
139
200
 
140
- async grep({ pattern, path: p = ".", glob: g }) {
201
+ async grep({ pattern, path: p, glob: g }) {
141
202
  const rx = new RegExp(pattern, "i");
142
203
  const gRx = g ? globToRegExp(g) : null;
143
204
  const out = [];
144
205
  function scanFile(full) {
145
- const relp = rel(full).split(path.sep).join("/");
206
+ const disp = displayPath(full);
207
+ const relp = disp.split(path.sep).join("/");
146
208
  if (gRx && !gRx.test(relp)) return;
147
209
  let txt;
148
210
  try {
@@ -155,33 +217,35 @@ export const TOOLS = {
155
217
  if (rx.test(l) && out.length < 200) out.push(`${relp}:${idx + 1}: ${l.trim().slice(0, 200)}`);
156
218
  });
157
219
  }
158
- // path có thể là FILE hoặc DIR — stat trước để không nuốt câm khi user trỏ thẳng vào file.
159
- let st;
160
- try {
161
- st = fssync.statSync(abs(p));
162
- } catch {
163
- return "No matches.";
220
+ function walkDir(dir) {
221
+ let ents;
222
+ try {
223
+ ents = fssync.readdirSync(dir, { withFileTypes: true });
224
+ } catch {
225
+ return;
226
+ }
227
+ for (const e of ents) {
228
+ if (SKIP_DIRS.has(e.name) || e.name.startsWith(".git")) continue;
229
+ const full = path.join(dir, e.name);
230
+ if (e.isDirectory()) {
231
+ walkDir(full);
232
+ continue;
233
+ }
234
+ scanFile(full);
235
+ }
164
236
  }
165
- if (st.isFile()) {
166
- scanFile(abs(p));
237
+ // Không truyền path → quét cwd + tất cả extra roots. Có path → chỉ vùng đó.
238
+ if (p == null || p === "" || p === ".") {
239
+ for (const root of listRoots()) walkDir(root);
167
240
  } else {
168
- (function walk(dir) {
169
- let ents;
170
- try {
171
- ents = fssync.readdirSync(dir, { withFileTypes: true });
172
- } catch {
173
- return;
174
- }
175
- for (const e of ents) {
176
- if (e.name === "node_modules" || e.name.startsWith(".git")) continue;
177
- const full = path.join(dir, e.name);
178
- if (e.isDirectory()) {
179
- walk(full);
180
- continue;
181
- }
182
- scanFile(full);
183
- }
184
- })(abs(p));
241
+ let st;
242
+ try {
243
+ st = fssync.statSync(abs(p));
244
+ } catch {
245
+ return "No matches.";
246
+ }
247
+ if (st.isFile()) scanFile(abs(p));
248
+ else walkDir(abs(p));
185
249
  }
186
250
  return out.length ? clip(out.join("\n")) : "No matches.";
187
251
  },
package/src/tui.js CHANGED
@@ -54,6 +54,7 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
54
54
  print() {},
55
55
  status() {},
56
56
  setBusy() {},
57
+ setMeta() {},
57
58
  setPrompt() {},
58
59
  read() {
59
60
  if (queue.length) return Promise.resolve(queue.shift());
@@ -79,7 +80,7 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
79
80
  // LUÔN thấy rõ "đang chạy", không bị tưởng treo.
80
81
  let busy = false;
81
82
  let busyLabel = "";
82
- let busyStartedAt = 0; // mốc thời gian để hiển thị elapsed
83
+ let busyMeta = ""; // chuỗi phụ (elapsed + token) repl đẩy vào qua setMeta()
83
84
  let frame = 0;
84
85
  let frameTimer = null;
85
86
  let prevRows = 0;
@@ -160,9 +161,31 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
160
161
  return promptLabel + arr.join("");
161
162
  }
162
163
  function topRow() {
163
- if (liveOut) return liveOut.slice(0, cols());
164
- if (statusText) return c.dim(FRAMES[frame % FRAMES.length] + " ") + statusText;
165
- if (busy) return c.dim(FRAMES[frame % FRAMES.length] + " " + (busyLabel || "đang chạy") + " · Ctrl+C để dừng");
164
+ if (liveOut) {
165
+ // Khi đang stream prose busy, ghép meta (elapsed+token) vào cuối liveOut
166
+ // để user vẫn thấy phiên đang sống không bị che status bar.
167
+ if (busy && busyMeta) {
168
+ const meta = c.dim(" · " + busyMeta);
169
+ const budget = Math.max(0, cols() - visLen(meta));
170
+ const head = liveOut.length > budget ? liveOut.slice(0, budget) : liveOut;
171
+ return head + meta;
172
+ }
173
+ return liveOut.slice(0, cols());
174
+ }
175
+ const spin = FRAMES[frame % FRAMES.length];
176
+ // Khi busy, LUÔN hiện elapsed+tokens (busyMeta) cạnh statusText/busyLabel để
177
+ // người dùng thấy phiên đang sống — kể cả lúc model im giữa các bước.
178
+ if (statusText) {
179
+ const meta = busy && busyMeta ? c.dim(" · " + busyMeta) : "";
180
+ const tail = busy ? c.dim(" · Ctrl+C để dừng") : "";
181
+ const line = c.dim(spin + " ") + statusText + meta + tail;
182
+ return line.length > cols() ? line.slice(0, cols()) : line;
183
+ }
184
+ if (busy) {
185
+ const meta = busyMeta ? " · " + busyMeta : "";
186
+ const line = c.dim(spin + " " + (busyLabel || "đang chạy") + meta + " · Ctrl+C để dừng");
187
+ return line.length > cols() ? line.slice(0, cols()) : line;
188
+ }
166
189
  return null;
167
190
  }
168
191
  function menuRows() {
@@ -455,8 +478,17 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
455
478
  setBusy(on, label) {
456
479
  busy = !!on;
457
480
  if (label != null) busyLabel = label;
481
+ if (!on) busyMeta = ""; // reset meta khi tắt busy để lượt sau không carry số cũ
458
482
  draw();
459
483
  },
484
+ setMeta(meta) {
485
+ // repl bơm chuỗi phụ (vd: "12s · ↑1.2k ↓340 (1.5k)") để status bar hiện
486
+ // realtime kể cả khi model im giữa các bước.
487
+ const next = meta || "";
488
+ if (next === busyMeta) return;
489
+ busyMeta = next;
490
+ if (busy) draw();
491
+ },
460
492
  setPrompt(label) {
461
493
  promptLabel = label || "";
462
494
  draw();