@noobdemon/noob-cli 1.0.6 → 1.0.8

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.0.6",
3
+ "version": "1.0.8",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/src/agent.js CHANGED
@@ -1,3 +1,4 @@
1
+ import os from "node:os";
1
2
  import { stream } from "./api.js";
2
3
  import { t } from "./i18n.js";
3
4
 
@@ -32,27 +33,86 @@ Available tools:
32
33
  - JSON in the tool block must be valid: escape newlines as \\n inside string values.
33
34
  - LANGUAGE: Always write your prose answers to the user in Vietnamese (tiếng Việt), unless the user explicitly writes in another language. Keep code, file paths, commands, and tool JSON unchanged.
34
35
 
36
+ # Coding principles (Karpathy) — apply to EVERY code change
37
+ 1. THINK FIRST: state the key assumptions before you code. If a requirement is ambiguous or a step is hard to reverse, ask ONE sharp question instead of guessing.
38
+ 2. KEEP IT SIMPLE: write the simplest thing that works. No speculative abstractions, no extra flags/config/layers "for later". Prefer deleting code to adding it.
39
+ 3. SURGICAL: change only what the task needs. No drive-by refactors, renames, reformatting, or comment churn in unrelated code.
40
+ 4. VERIFIABLE GOAL: decide how you'll know it works, then check it (run the build/test, read the output). Report what you verified — and honestly state what you did NOT verify.
41
+
35
42
  # Example interaction
36
43
  ## USER
37
- how many lines are in app.js?
44
+ do the tests pass?
38
45
  ## ASSISTANT
39
46
  \`\`\`tool
40
- {"name": "run_command", "input": {"command": "wc -l app.js"}}
47
+ {"name": "run_command", "input": {"command": "npm test"}}
41
48
  \`\`\`
42
49
  ## TOOL RESULT (run_command)
43
- 42 app.js
50
+ 12 passing (340ms)
44
51
  [exit code 0]
45
52
  ## ASSISTANT
46
- app.js has 42 lines.
53
+ cả 12 test đều pass.
47
54
 
48
55
  Follow this pattern exactly. Your very first response to a task that needs the filesystem MUST be a tool block — do not refuse or explain limitations.`;
49
56
 
50
57
  const MAX_STEPS = 30;
58
+ const MAX_PROMPT_CHARS = 80000; // ngân sách ký tự cho phần hội thoại gửi lên model
59
+
60
+ // Môi trường chạy thực: model cần biết OS + shell để emit lệnh ĐÚNG. Không có
61
+ // khối này, trên Windows model hay emit lệnh Unix (wc/ls/cat/grep) → run_command
62
+ // (PowerShell) báo lỗi.
63
+ function runtimeContext() {
64
+ const isWin = process.platform === "win32";
65
+ const lines = [
66
+ "# ENVIRONMENT",
67
+ `- OS: ${process.platform} (${os.release()})`,
68
+ `- Shell for run_command: ${isWin ? "Windows PowerShell (powershell.exe)" : "bash"}`,
69
+ `- Current working directory: ${process.cwd()}`,
70
+ ];
71
+ if (isWin) {
72
+ lines.push(
73
+ "- IMPORTANT: run_command runs in PowerShell on Windows — do NOT use Unix tools.",
74
+ " Use: Get-Content (not cat), Get-ChildItem (not ls), Select-String (not grep),",
75
+ " (Get-Content f | Measure-Object -Line) (not wc -l). Paths use backslashes.",
76
+ "- Prefer the dedicated tools (read_file / list_dir / grep / glob) over shell commands;",
77
+ " they are cross-platform. Use run_command mainly for builds/tests/installs.",
78
+ );
79
+ } else {
80
+ lines.push("- Prefer the dedicated tools (read_file/list_dir/grep/glob) over shell when possible.");
81
+ }
82
+ return lines.join("\n");
83
+ }
84
+
85
+ // Lược ngữ cảnh để không vượt context khi phiên dài. KHÔNG đụng vào history thật
86
+ // (vẫn lưu/đầy đủ để resume) — chỉ thu gọn BẢN SAO dùng cho prompt.
87
+ function compact(history, budget) {
88
+ const len = (m) => (m.content || "").length + 24;
89
+ let total = history.reduce((s, m) => s + len(m), 0);
90
+ if (total <= budget) return history;
91
+ const out = history.map((m) => ({ ...m }));
92
+ // (1) Rút gọn các TOOL RESULT cũ trước (giữ 5 cái gần nhất) — đây là phần phình
93
+ // nhất (đọc file lớn) và model đã xử lý xong rồi.
94
+ const toolIdx = out.map((m, i) => (m.role === "tool" ? i : -1)).filter((i) => i >= 0);
95
+ for (const i of toolIdx.slice(0, Math.max(0, toolIdx.length - 5))) {
96
+ if (total <= budget) break;
97
+ const before = len(out[i]);
98
+ out[i].content = "[kết quả công cụ cũ đã được lược bớt để tiết kiệm ngữ cảnh]";
99
+ total -= before - len(out[i]);
100
+ }
101
+ if (total <= budget) return out;
102
+ // (2) Vẫn dài → bỏ các lượt cũ nhất, giữ USER đầu tiên (mô tả nhiệm vụ gốc) +
103
+ // 12 message gần nhất.
104
+ const firstUser = out.findIndex((m) => m.role === "user");
105
+ const head = firstUser >= 0 ? [out[firstUser]] : [];
106
+ const tailStart = Math.max(firstUser + 1, out.length - 12);
107
+ const elided = { role: "tool", name: "context", content: "[… các lượt trước đã được lược bớt …]" };
108
+ return [...head, elided, ...out.slice(tailStart)];
109
+ }
51
110
 
52
111
  // The proxy is stateless, so we serialize the whole transcript into one prompt.
53
112
  function buildPrompt(history) {
54
- const parts = [SYSTEM, "", "=".repeat(60), "# CONVERSATION", ""];
55
- for (const m of history) {
113
+ const msgs = compact(history, MAX_PROMPT_CHARS);
114
+ const parts = [SYSTEM, "", runtimeContext(), "", "=".repeat(60), "# CONVERSATION", ""];
115
+ for (const m of msgs) {
56
116
  if (m.role === "user") parts.push(`## USER\n${m.content}`);
57
117
  else if (m.role === "assistant") parts.push(`## ASSISTANT\n${m.content}`);
58
118
  else if (m.role === "tool") parts.push(`## TOOL RESULT (${m.name})\n${m.content}`);
@@ -95,11 +155,19 @@ export function parseToolCall(text) {
95
155
  * @param {(msg:string)=>void} opts.onStatus thinking/streaming status
96
156
  * @returns {Promise<string>} the final assistant answer (no tool block)
97
157
  */
98
- export async function runAgent({ history, model, signal, onTool, onStatus }) {
158
+ export async function runAgent({ history, model, signal, onTool, onStatus, onDelta }) {
99
159
  for (let step = 0; step < MAX_STEPS; step++) {
100
160
  const prompt = buildPrompt(history);
101
161
  onStatus?.("thinking");
102
- const { text } = await stream({ mode: "chat", model, message: prompt, signal });
162
+ onDelta?.({ type: "step-start" });
163
+ const { text } = await stream({
164
+ mode: "chat",
165
+ model,
166
+ message: prompt,
167
+ signal,
168
+ onDelta: (d) => onDelta?.({ type: "delta", text: d }),
169
+ });
170
+ onDelta?.({ type: "step-end" });
103
171
  history.push({ role: "assistant", content: text });
104
172
 
105
173
  const call = parseToolCall(text);
package/src/api.js CHANGED
@@ -43,57 +43,89 @@ async function parseError(resp) {
43
43
  * Stream a chat/merge/search request from the gateway.
44
44
  * @returns {Promise<{text:string, reasoning:string}>}
45
45
  */
46
- export async function stream({ mode = "chat", message, model, signal, onDelta, onReasoning, onStatus }) {
46
+ export async function stream({ mode = "chat", message, model, signal, onDelta, onReasoning, onStatus, idleMs = 120000 }) {
47
47
  const endpoint = mode === "search" ? "/api/search" : mode === "merge" ? "/api/merge" : "/api/chat";
48
48
  const body = mode === "search" ? { query: message } : mode === "merge" ? { message } : { message, model };
49
49
 
50
- const resp = await fetch(config.gatewayUrl + endpoint, {
51
- method: "POST",
52
- headers: authHeaders(),
53
- body: JSON.stringify(body),
54
- signal,
55
- });
56
-
57
- if (!resp.ok || !resp.body) throw await parseError(resp);
50
+ // Idle-timeout: nếu KHÔNG nhận được byte nào trong idleMs (kết nối treo), tự
51
+ // huỷ và báo lỗi rõ ràng — thay vì spinner quay vô tận. Vẫn tôn trọng signal
52
+ // của người dùng (Ctrl+C). Phân biệt 2 trường hợp qua cờ `timedOut`.
53
+ const ctrl = new AbortController();
54
+ let timedOut = false;
55
+ const onUserAbort = () => ctrl.abort();
56
+ signal?.addEventListener("abort", onUserAbort, { once: true });
57
+ let idle;
58
+ const arm = () => {
59
+ clearTimeout(idle);
60
+ idle = setTimeout(() => {
61
+ timedOut = true;
62
+ ctrl.abort();
63
+ }, idleMs);
64
+ };
58
65
 
59
- const reader = resp.body.getReader();
60
- const decoder = new TextDecoder();
61
- let buf = "";
62
66
  let text = "";
63
67
  let reasoning = "";
64
68
 
65
- while (true) {
66
- const { done, value } = await reader.read();
67
- if (done) break;
68
- buf += decoder.decode(value, { stream: true });
69
- let nl;
70
- while ((nl = buf.indexOf("\n")) !== -1) {
71
- const line = buf.slice(0, nl).trim();
72
- buf = buf.slice(nl + 1);
73
- if (!line.startsWith("data:")) continue;
74
- const data = line.slice(5).trim();
75
- if (!data) continue;
76
- let p;
77
- try {
78
- p = JSON.parse(data);
79
- } catch {
80
- continue;
81
- }
82
- if (p.status && onStatus) onStatus(p.status);
83
- if (p.delta) {
84
- text += p.delta;
85
- onDelta?.(p.delta);
86
- }
87
- if (p.reasoning) {
88
- reasoning = p.reasoning;
89
- onReasoning?.(p.reasoning);
90
- if (p.answer) text = p.answer;
69
+ // Một dòng SSE → cập nhật text/reasoning. Tách ra để dùng lại khi flush dòng cuối.
70
+ const processLine = (rawLine) => {
71
+ const line = rawLine.trim();
72
+ if (!line.startsWith("data:")) return;
73
+ const data = line.slice(5).trim();
74
+ if (!data) return;
75
+ let p;
76
+ try {
77
+ p = JSON.parse(data);
78
+ } catch {
79
+ return;
80
+ }
81
+ if (p.status && onStatus) onStatus(p.status);
82
+ if (p.delta) {
83
+ text += p.delta;
84
+ onDelta?.(p.delta);
85
+ }
86
+ if (p.reasoning) {
87
+ reasoning = p.reasoning;
88
+ onReasoning?.(p.reasoning);
89
+ if (p.answer) text = p.answer;
90
+ }
91
+ if (p.error) throw new ApiError(p.error, {});
92
+ };
93
+
94
+ try {
95
+ arm();
96
+ const resp = await fetch(config.gatewayUrl + endpoint, {
97
+ method: "POST",
98
+ headers: authHeaders(),
99
+ body: JSON.stringify(body),
100
+ signal: ctrl.signal,
101
+ });
102
+ if (!resp.ok || !resp.body) throw await parseError(resp);
103
+
104
+ const reader = resp.body.getReader();
105
+ const decoder = new TextDecoder();
106
+ let buf = "";
107
+ while (true) {
108
+ const { done, value } = await reader.read();
109
+ arm(); // có hoạt động → gia hạn idle
110
+ if (done) break;
111
+ buf += decoder.decode(value, { stream: true });
112
+ let nl;
113
+ while ((nl = buf.indexOf("\n")) !== -1) {
114
+ processLine(buf.slice(0, nl));
115
+ buf = buf.slice(nl + 1);
91
116
  }
92
- if (p.error) throw new ApiError(p.error, { status: resp.status });
93
117
  }
94
- }
118
+ buf += decoder.decode(); // flush decoder
119
+ if (buf.trim()) processLine(buf); // dòng SSE cuối không có '\n' — đừng bỏ sót
95
120
 
96
- return { text: text.trim(), reasoning: reasoning.trim() };
121
+ return { text: text.trim(), reasoning: reasoning.trim() };
122
+ } catch (err) {
123
+ if (timedOut) throw new ApiError("Kết nối tới máy chủ quá thời gian chờ (treo).", { code: "timeout" });
124
+ throw err;
125
+ } finally {
126
+ clearTimeout(idle);
127
+ signal?.removeEventListener("abort", onUserAbort);
128
+ }
97
129
  }
98
130
 
99
131
  /** Fetch the current key's quota/usage from the gateway (no request consumed). */
package/src/i18n.js CHANGED
@@ -11,6 +11,9 @@ export const t = {
11
11
  pressAgainToExit: "nhấn Ctrl+C lần nữa để thoát",
12
12
  running: "đang chạy…",
13
13
  denied: "đã từ chối",
14
+ queued: (n, txt) => `⏎ đã xếp hàng [${n}] · gửi khi model xong: ${txt}`,
15
+ queueCleared: (n) => `(đã xoá ${n} tin đang xếp hàng)`,
16
+ permRetry: "→ gõ y (đồng ý) · n (từ chối) · a (luôn cho phép)",
14
17
 
15
18
  // auth
16
19
  notLoggedIn:
@@ -61,8 +64,8 @@ export const t = {
61
64
  cmdVersion: "/version /v xem version hiện tại + trạng thái yolo",
62
65
  cmdExit: "/exit /quit thoát",
63
66
  tip1: "• Mô tả việc cần làm; noob sẽ đọc/sửa file & chạy lệnh giúp bạn.",
64
- tip2: "• Thao tác nguy hiểm sẽ hỏi phép, trừ khi bật yolo (Shift+Tab).",
65
- tip3: "• Ctrl+C 1 lần = dừng lượt hiện tại, 2 lần = thoát. CLI không tự tắt sau khi xong.",
67
+ tip2: "• Đang chạy vẫn tiếp được — tin sẽ xếp hàng & tự gửi khi model xong.",
68
+ tip3: "• Shift+Tab: bật/tắt yolo nhanh. Ctrl+C 1 lần = dừng lượt, 2 lần = thoát.",
66
69
 
67
70
  // misc
68
71
  yoloOn: "⚠ yolo BẬT — tự động duyệt mọi thao tác sửa file & chạy lệnh",
package/src/repl.js CHANGED
@@ -44,9 +44,12 @@ export async function startRepl(opts = {}) {
44
44
  let closed = false; // đã ngừng đọc vĩnh viễn
45
45
  let exiting = false; // ta chủ động thoát (/exit, Ctrl+C ×2)
46
46
  let lastPrompt = ""; // áp lại sau khi dựng lại interface
47
- const queue = []; // lines đã gõ/pipe, dùng theo thứ tự
48
- let waiter = null; // resolver đang chờ dòng kế tiếp
49
- let closeBurst = 0;
47
+ // Hàng đợi tin nhắn (giống Claude Code): khi model đang chạy, thêm câu hỏi
48
+ // xếp vào `pending`; xong turn, main loop tự lấy câu kế tiếp gửi lên. CHỈ
49
+ // main loop tiêu thụ `pending`. Câu trả lời permission đi qua `waiter` riêng,
50
+ // nên tin xếp hàng KHÔNG bị nhầm thành câu trả lời "cho phép?".
51
+ const pending = [];
52
+ let waiter = null; // resolver đang chờ một dòng tươi (prompt / permission)
50
53
  let closeAt = 0;
51
54
 
52
55
  function deliver(line) {
@@ -54,9 +57,11 @@ export async function startRepl(opts = {}) {
54
57
  const w = waiter;
55
58
  waiter = null;
56
59
  w(line);
57
- } else {
58
- queue.push(line); // type-ahead / buffered — không bao giờ mất
60
+ return;
59
61
  }
62
+ // Không ai đang hỏi → đây là tin xếp hàng cho lượt kế tiếp.
63
+ pending.push(line);
64
+ if (process.stdin.isTTY) console.log(c.dim(" " + t.queued(pending.length, truncate(line, 60))));
60
65
  }
61
66
  function endInput() {
62
67
  closed = true;
@@ -70,31 +75,41 @@ export async function startRepl(opts = {}) {
70
75
  const r = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: "" });
71
76
  r.on("line", deliver);
72
77
  r.on("close", () => {
73
- if (exiting || !process.stdin.isTTY) return endInput(); // thoát thật
74
- const now = Date.now();
75
- if (now - closeAt > 1500) {
76
- closeAt = now;
77
- closeBurst = 0;
78
- }
79
- if (++closeBurst > 8) return endInput(); // stdin mất thật → khỏi quay vòng
80
- // 'close' bất thường: dựng lại interface, hiện lại prompt; reader vẫn chờ.
81
- rl = buildRl();
82
- if (lastPrompt) {
83
- rl.setPrompt(lastPrompt);
84
- rl.prompt();
78
+ if (exiting) return endInput(); // ta chủ động thoát
79
+ // EOF THẬT: stdin đã end/destroy (Ctrl+Z trên Windows, Ctrl+D trên *nix),
80
+ // hoặc stdin không phải TTY (piped) đã đọc hết. Chỉ lúc đó mới dừng.
81
+ if (!process.stdin.isTTY || process.stdin.readableEnded || process.stdin.destroyed) {
82
+ return endInput();
85
83
  }
84
+ // 'close' BẤT THƯỜNG trên một TTY còn sống (tranh chấp console khi
85
+ // paste/đa dòng, tiến trình con, v.v.) → KHÔNG BAO GIỜ thoát. Dựng lại
86
+ // interface và hiện lại prompt; reader đang chờ vẫn được giữ nguyên. Nếu
87
+ // close dồn dập thì hoãn 50ms để khỏi quay CPU — nhưng vẫn sống.
88
+ const now = Date.now();
89
+ const fast = now - closeAt < 50;
90
+ closeAt = now;
91
+ const rebuild = () => {
92
+ rl = buildRl();
93
+ if (lastPrompt) {
94
+ rl.setPrompt(lastPrompt);
95
+ rl.prompt();
96
+ }
97
+ };
98
+ if (fast) setTimeout(rebuild, 50);
99
+ else rebuild();
86
100
  });
87
101
  return r;
88
102
  }
89
103
  rl = buildRl();
90
104
 
91
105
  function nextLine() {
92
- if (queue.length) return Promise.resolve(queue.shift());
93
106
  if (closed) return Promise.resolve(null);
94
107
  return new Promise((res) => (waiter = res));
95
108
  }
109
+ // Dòng "tươi": dùng cho prompt chính VÀ cho permission. KHÔNG đụng tới
110
+ // `pending` (hàng đợi tin nhắn) — chỉ main loop mới rút từ `pending`.
96
111
  function ask(prompt) {
97
- if (closed && !queue.length) return Promise.resolve(null);
112
+ if (closed) return Promise.resolve(null);
98
113
  lastPrompt = prompt;
99
114
  rl.setPrompt(prompt);
100
115
  rl.prompt();
@@ -124,6 +139,11 @@ export async function startRepl(opts = {}) {
124
139
  if (abort) {
125
140
  abort.abort();
126
141
  abort = null;
142
+ if (pending.length) {
143
+ const n = pending.length;
144
+ pending.length = 0; // ngắt lượt → xoá luôn tin đang xếp hàng
145
+ console.log(c.dim(" " + t.queueCleared(n)));
146
+ }
127
147
  console.log(c.err("\n ✗ " + t.interrupted));
128
148
  return; // the main loop will redraw the prompt
129
149
  }
@@ -260,9 +280,16 @@ export async function startRepl(opts = {}) {
260
280
 
261
281
  // Main loop — runs until /exit, double Ctrl+C, or EOF. Never exits after a task.
262
282
  while (true) {
263
- const raw = await ask(promptStr());
264
- if (raw == null) break; // stdin fully closed and drained
265
- const input = raw.trim();
283
+ let input;
284
+ if (pending.length) {
285
+ // tin xếp hàng → tự gửi câu kế tiếp (không chờ gõ).
286
+ input = (pending.shift() ?? "").trim();
287
+ if (process.stdin.isTTY && input) console.log(promptStr() + input);
288
+ } else {
289
+ const raw = await ask(promptStr());
290
+ if (raw == null) break; // stdin fully closed and drained
291
+ input = raw.trim();
292
+ }
266
293
  if (!input) continue;
267
294
  // Bọc cả lượt: một lỗi trong xử lý lệnh/agent không được phép thoát ra
268
295
  // ngoài vòng lặp (sẽ rơi vào .catch ở bin/noob.js → process.exit(1) =
@@ -297,48 +324,77 @@ export async function startRepl(opts = {}) {
297
324
  const elapsed = ((Date.now() - t0) / 1000).toFixed(0);
298
325
  spinner.text = c.dim(`${label}… ${elapsed}s`);
299
326
  };
327
+ const stopSpin = () => {
328
+ if (timer) {
329
+ clearInterval(timer);
330
+ timer = null;
331
+ }
332
+ if (spinner.isSpinning) spinner.stop();
333
+ };
334
+ const startSpin = (label) => {
335
+ if (!spinner.isSpinning) spinner.start();
336
+ if (!timer) timer = setInterval(() => tick(label), 200);
337
+ };
300
338
 
301
339
  try {
302
340
  if (state.mode !== "chat") {
303
- spinner.start();
304
- timer = setInterval(() => tick(state.mode === "search" ? t.searching : t.merging), 200);
341
+ const name = state.mode === "search" ? "Tìm web" : "Merge AI";
342
+ const label = state.mode === "search" ? t.searching : t.merging;
343
+ startSpin(label);
344
+ const printer = makeStreamPrinter(name, "#f59e0b");
305
345
  const { text: answer } = await stream({
306
346
  mode: state.mode,
307
347
  message: text,
308
348
  signal: abort.signal,
309
- onStatus: (s) => (spinner.text = c.dim(" " + s)),
349
+ onStatus: (s) => {
350
+ if (!printer.started) spinner.text = c.dim(" " + s);
351
+ },
352
+ onDelta: (d) => {
353
+ stopSpin();
354
+ printer.push(d);
355
+ },
310
356
  });
311
- clearInterval(timer);
312
- spinner.stop();
313
- printAnswer(answer, state.mode === "search" ? "Tìm web" : "Merge AI", "#f59e0b");
357
+ stopSpin();
358
+ printer.flush();
359
+ if (!printer.started) printAnswer(answer, name, "#f59e0b");
314
360
  return;
315
361
  }
316
362
 
317
363
  state.history.push({ role: "user", content: text });
318
- spinner.start();
319
- timer = setInterval(() => tick(t.thinking), 200);
364
+ startSpin(t.thinking);
365
+ let printer = null;
320
366
 
321
367
  const answer = await runAgent({
322
368
  history: state.history,
323
369
  model: state.model.id,
324
370
  signal: abort.signal,
325
371
  onStatus: () => tick(t.thinking),
372
+ onDelta: (ev) => {
373
+ if (ev.type === "step-start") {
374
+ printer = makeStreamPrinter(state.model.name, providerColor(state.model.provider));
375
+ } else if (ev.type === "delta") {
376
+ if (printer.suppressing) return printer.push(ev.text); // nuốt tool JSON → để spinner chạy
377
+ stopSpin();
378
+ printer.push(ev.text);
379
+ if (printer.suppressing) startSpin(t.thinking); // vừa chuyển sang soạn tool
380
+ } else if (ev.type === "step-end") {
381
+ printer?.flush();
382
+ }
383
+ },
326
384
  onTool: async (name, input) => {
327
- clearInterval(timer);
328
- spinner.stop();
385
+ stopSpin();
329
386
  const res = await execTool(name, input);
330
- spinner.start();
331
- timer = setInterval(() => tick(t.thinking), 200);
387
+ startSpin(t.thinking);
332
388
  return res;
333
389
  },
334
390
  });
335
391
 
336
- clearInterval(timer);
337
- spinner.stop();
338
- printAnswer(answer, state.model.name, providerColor(state.model.provider));
392
+ stopSpin();
393
+ // Lượt cuối không stream ra gì (vd model suy luận trả 1 cục) → in fallback.
394
+ if ((!printer || !printer.started) && answer?.trim())
395
+ printAnswer(answer, state.model.name, providerColor(state.model.provider));
339
396
  } catch (err) {
340
- clearInterval(timer);
341
- spinner.stop();
397
+ stopSpin();
342
398
  if (err.name === "AbortError") return;
343
399
  printError(err);
344
400
  } finally {
@@ -355,11 +411,9 @@ export async function startRepl(opts = {}) {
355
411
  else if (name === "edit_file") preview(`- ${truncate(input.old_string)}\n+ ${truncate(input.new_string)}`, input.path);
356
412
 
357
413
  if (DESTRUCTIVE.has(name) && !state.yolo && !state.autoApprove.has(name)) {
358
- const a = ((await ask(c.tool(" cho phép? ") + c.dim("[y] có / [n] không / [a] luôn " + name + " › "))) ?? "n")
359
- .trim()
360
- .toLowerCase();
414
+ const a = await askPermission(name);
361
415
  if (a === "a") state.autoApprove.add(name);
362
- else if (a !== "y" && a !== "") {
416
+ else if (a === "n") {
363
417
  console.log(c.err(" " + t.denied));
364
418
  return { allow: false };
365
419
  }
@@ -378,6 +432,27 @@ export async function startRepl(opts = {}) {
378
432
  }
379
433
  }
380
434
 
435
+ // Đọ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
436
+ // Enter = đồng ý). Nếu nhận một dòng lạ & dài — gần như chắc là một tin nhắn
437
+ // bị paste/gõ nhầm vào đúng lúc prompt hiện ra (đây là thủ phạm làm hỏng &
438
+ // "tự tắt" trước đây) — thì KHÔNG coi là từ chối: xếp nó vào hàng đợi tin
439
+ // 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.
440
+ async function askPermission(name) {
441
+ while (true) {
442
+ const raw = await ask(c.tool(" cho phép? ") + c.dim("[y] có / [n] không / [a] luôn " + name + " › "));
443
+ if (raw == null) return "n"; // stdin đóng thật
444
+ const a = raw.trim().toLowerCase();
445
+ if (a === "" || a === "y" || a === "yes" || a === "có") return "y";
446
+ if (a === "n" || a === "no" || a === "không") return "n";
447
+ if (a === "a" || a === "always" || a === "luôn") return "a";
448
+ if (raw.trim().length > 3) {
449
+ pending.push(raw); // tin nhắn lạc → xếp hàng, gửi sau khi xong lượt
450
+ console.log(c.dim(" " + t.queued(pending.length, truncate(raw, 60))));
451
+ }
452
+ console.log(c.dim(" " + t.permRetry));
453
+ }
454
+ }
455
+
381
456
  // ── slash commands ─────────────────────────────────────────────────────
382
457
  async function command(input) {
383
458
  const [cmd, ...rest] = input.slice(1).split(/\s+/);
@@ -540,6 +615,56 @@ function printAnswer(text, name, color) {
540
615
  );
541
616
  }
542
617
 
618
+ // In câu trả lời theo dòng token thời gian thực. Vì model emit lời + (tuỳ chọn)
619
+ // MỘT khối ```tool ở cuối, ta giấu mọi thứ từ ```tool trở đi (người dùng chỉ
620
+ // 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
621
+ // nửa vời "```to" trước khi kịp nhận ra đó là fence.
622
+ function makeStreamPrinter(name, color) {
623
+ let buf = "";
624
+ let printed = 0;
625
+ let suppress = false;
626
+ let started = false;
627
+ let header = false;
628
+ const HOLD = 8;
629
+ const write = (s) => {
630
+ if (!s) return;
631
+ if (!header) {
632
+ process.stdout.write("\n" + chalk.hex(color).bold(" ● " + name) + "\n ");
633
+ header = true;
634
+ }
635
+ process.stdout.write(s.replace(/\n/g, "\n "));
636
+ started = true;
637
+ };
638
+ return {
639
+ get started() {
640
+ return started;
641
+ },
642
+ get suppressing() {
643
+ return suppress;
644
+ },
645
+ push(delta) {
646
+ buf += delta;
647
+ if (suppress) return;
648
+ const f = buf.indexOf("```tool");
649
+ if (f !== -1) {
650
+ write(buf.slice(printed, f));
651
+ printed = buf.length;
652
+ suppress = true;
653
+ return;
654
+ }
655
+ const safeEnd = Math.max(printed, buf.length - HOLD);
656
+ if (safeEnd > printed) {
657
+ write(buf.slice(printed, safeEnd));
658
+ printed = safeEnd;
659
+ }
660
+ },
661
+ flush() {
662
+ if (!suppress && printed < buf.length) write(buf.slice(printed));
663
+ if (started) process.stdout.write("\n");
664
+ },
665
+ };
666
+ }
667
+
543
668
  function printError(err) {
544
669
  const map = {
545
670
  missing_key: t.errMissingKey,