@noobdemon/noob-cli 1.1.2 → 1.2.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 +1 -1
- package/src/agent.js +6 -1
- package/src/i18n.js +3 -0
- package/src/repl.js +90 -117
- package/src/tui.js +312 -0
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -177,8 +177,13 @@ export function parseToolCall(text) {
|
|
|
177
177
|
* @param {(msg:string)=>void} opts.onStatus thinking/streaming status
|
|
178
178
|
* @returns {Promise<string>} the final assistant answer (no tool block)
|
|
179
179
|
*/
|
|
180
|
-
export async function runAgent({ history, model, signal, onTool, onStatus, onDelta }) {
|
|
180
|
+
export async function runAgent({ history, model, signal, onTool, onStatus, onDelta, onSteer }) {
|
|
181
181
|
for (let step = 0; step < MAX_STEPS; step++) {
|
|
182
|
+
// Steering: tin nhắn người dùng gõ GIỮA CHỪNG được chèn vào hội thoại TRƯỚC
|
|
183
|
+
// lần gọi model kế tiếp → model thấy và điều chỉnh ngay trong cùng task.
|
|
184
|
+
const steer = onSteer?.() || [];
|
|
185
|
+
for (const msg of steer) history.push({ role: "user", content: msg });
|
|
186
|
+
|
|
182
187
|
const prompt = buildPrompt(history);
|
|
183
188
|
onStatus?.("thinking");
|
|
184
189
|
onDelta?.({ type: "step-start" });
|
package/src/i18n.js
CHANGED
|
@@ -13,6 +13,9 @@ export const t = {
|
|
|
13
13
|
denied: "đã từ chối",
|
|
14
14
|
queued: (n, txt) => `⏎ đã xếp hàng [${n}] · gửi khi model xong: ${txt}`,
|
|
15
15
|
queueCleared: (n) => `(đã xoá ${n} tin đang xếp hàng)`,
|
|
16
|
+
steerHint: "💬 Gõ + Enter bất cứ lúc nào để chèn ý cho AI giữa chừng (không ngắt task đang chạy).",
|
|
17
|
+
steerWillInject: (txt) => `💬 sẽ chèn cho AI ở bước tới: ${txt}`,
|
|
18
|
+
steerInject: (txt) => `💬 đã chèn cho AI: ${txt}`,
|
|
16
19
|
permRetry: "→ gõ y (đồng ý) · n (từ chối) · a (luôn cho phép)",
|
|
17
20
|
|
|
18
21
|
// auth
|
package/src/repl.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import readline from "node:readline";
|
|
2
1
|
import process from "node:process";
|
|
3
|
-
import ora from "ora";
|
|
4
2
|
import chalk from "chalk";
|
|
3
|
+
import { createTui } from "./tui.js";
|
|
5
4
|
import { runAgent } from "./agent.js";
|
|
6
5
|
import { stream, usage, ApiError } from "./api.js";
|
|
7
6
|
import { runTool, describe, DESTRUCTIVE } from "./tools.js";
|
|
@@ -40,97 +39,35 @@ export async function startRepl(opts = {}) {
|
|
|
40
39
|
// nguyên reader đang chờ (người dùng chỉ thấy prompt hiện lại). Chỉ dừng thật
|
|
41
40
|
// khi: /exit, Ctrl+C hai lần, hoặc EOF thật (stdin piped đã cạn / 'close' dồn
|
|
42
41
|
// dập nghĩa là stdin đã mất).
|
|
43
|
-
let
|
|
44
|
-
let
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
//
|
|
48
|
-
// →
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
closed = true;
|
|
68
|
-
if (waiter) {
|
|
69
|
-
const w = waiter;
|
|
70
|
-
waiter = null;
|
|
71
|
-
w(null);
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
function buildRl() {
|
|
75
|
-
const r = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: "" });
|
|
76
|
-
r.on("line", deliver);
|
|
77
|
-
r.on("close", () => {
|
|
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();
|
|
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();
|
|
100
|
-
});
|
|
101
|
-
return r;
|
|
102
|
-
}
|
|
103
|
-
rl = buildRl();
|
|
42
|
+
let closed = false;
|
|
43
|
+
let exiting = false;
|
|
44
|
+
const pending = []; // hàng đợi tin nhắn (steering giữa task / lượt mới)
|
|
45
|
+
|
|
46
|
+
// Toàn bộ I/O qua `tui`: thanh nhập ghim đáy, output cuộn trên, paste nhiều
|
|
47
|
+
// dòng → "[pasted N lines]" nhưng gửi đủ. Không phải TTY / NOOB_TUI=0 → dumb
|
|
48
|
+
// mode = đọc dòng thường (an toàn pipe/CI/terminal lạ).
|
|
49
|
+
const tui = createTui({
|
|
50
|
+
onLine: (line) => {
|
|
51
|
+
// Submit khi KHÔNG có read() đang chờ = tin xếp hàng. Đang chạy task → sẽ
|
|
52
|
+
// CHÈN cho AI ở bước kế tiếp (steering); rảnh → gửi như lượt mới.
|
|
53
|
+
pending.push(line);
|
|
54
|
+
tui.print(abort ? c.user(" " + t.steerWillInject(truncate(line, 60))) : c.dim(" " + t.queued(pending.length, truncate(line, 60))));
|
|
55
|
+
},
|
|
56
|
+
onInterrupt: () => interrupt(),
|
|
57
|
+
onEOF: () => {
|
|
58
|
+
closed = true;
|
|
59
|
+
},
|
|
60
|
+
onShiftTab: () => {
|
|
61
|
+
state.yolo = !state.yolo;
|
|
62
|
+
tui.print(state.yolo ? c.err(" " + t.yoloOn) : c.ok(" " + t.yoloOff));
|
|
63
|
+
tui.setPrompt(promptStr(false));
|
|
64
|
+
},
|
|
65
|
+
});
|
|
104
66
|
|
|
105
|
-
|
|
106
|
-
if (closed) return Promise.resolve(null);
|
|
107
|
-
return new Promise((res) => (waiter = res));
|
|
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`.
|
|
67
|
+
// Dòng "tươi" cho prompt chính VÀ permission. KHÔNG đụng `pending`.
|
|
111
68
|
function ask(prompt) {
|
|
112
69
|
if (closed) return Promise.resolve(null);
|
|
113
|
-
|
|
114
|
-
rl.setPrompt(prompt);
|
|
115
|
-
rl.prompt();
|
|
116
|
-
return nextLine();
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// Shift+Tab — bật/tắt yolo nhanh (best-effort; gắn vào stdin nên sống qua mọi
|
|
120
|
-
// lần dựng lại rl; bọc try/catch để không bao giờ làm hỏng input).
|
|
121
|
-
if (process.stdin.isTTY) {
|
|
122
|
-
try {
|
|
123
|
-
readline.emitKeypressEvents(process.stdin);
|
|
124
|
-
process.stdin.on("keypress", (_str, key) => {
|
|
125
|
-
if (!key || key.name !== "tab" || !key.shift) return;
|
|
126
|
-
state.yolo = !state.yolo;
|
|
127
|
-
console.log(state.yolo ? c.err(" " + t.yoloOn) : c.ok(" " + t.yoloOff));
|
|
128
|
-
rl.setPrompt(promptStr(false)); // cập nhật dòng trạng thái ngay lập tức
|
|
129
|
-
rl.prompt(true);
|
|
130
|
-
});
|
|
131
|
-
} catch {
|
|
132
|
-
/* không có Shift+Tab cũng được — vẫn dùng /yolo */
|
|
133
|
-
}
|
|
70
|
+
return tui.read(prompt);
|
|
134
71
|
}
|
|
135
72
|
|
|
136
73
|
// NOOB_DEBUG=1: vạch trần đường thoát thật sự (xác nhận/loại bỏ giả thuyết
|
|
@@ -143,7 +80,19 @@ export async function startRepl(opts = {}) {
|
|
|
143
80
|
|
|
144
81
|
let abort = null; // active turn controller
|
|
145
82
|
let sigintArmed = false;
|
|
146
|
-
|
|
83
|
+
let sigintTimer = null;
|
|
84
|
+
let lastInterrupt = 0;
|
|
85
|
+
|
|
86
|
+
// Ctrl+C — một đường xử lý duy nhất, gọi từ 3 nguồn (xem bên dưới) vì readline
|
|
87
|
+
// ở chế độ terminal/raw NUỐT SIGINT của process trên Windows. Debounce 80ms để
|
|
88
|
+
// gộp các nguồn trùng nhau thành MỘT lần nhấn.
|
|
89
|
+
// • đang chạy task → 1 lần = NGỪNG task (không thoát)
|
|
90
|
+
// • đang rảnh → 1 lần = đếm 1.5s; thêm 1 lần trong 1.5s = THOÁT CLI
|
|
91
|
+
function interrupt() {
|
|
92
|
+
const now = Date.now();
|
|
93
|
+
if (now - lastInterrupt < 80) return; // gộp SIGINT + 'SIGINT' của rl + keypress
|
|
94
|
+
lastInterrupt = now;
|
|
95
|
+
|
|
147
96
|
if (abort) {
|
|
148
97
|
abort.abort();
|
|
149
98
|
abort = null;
|
|
@@ -153,17 +102,34 @@ export async function startRepl(opts = {}) {
|
|
|
153
102
|
console.log(c.dim(" " + t.queueCleared(n)));
|
|
154
103
|
}
|
|
155
104
|
console.log(c.err("\n ✗ " + t.interrupted));
|
|
156
|
-
|
|
105
|
+
sigintArmed = false;
|
|
106
|
+
if (sigintTimer) {
|
|
107
|
+
clearTimeout(sigintTimer);
|
|
108
|
+
sigintTimer = null;
|
|
109
|
+
}
|
|
110
|
+
if (!closed) tui.setPrompt(promptStr(false));
|
|
111
|
+
return;
|
|
157
112
|
}
|
|
113
|
+
|
|
158
114
|
if (sigintArmed) {
|
|
115
|
+
// lần 2 trong cửa sổ 1.5s → thoát
|
|
116
|
+
if (sigintTimer) clearTimeout(sigintTimer);
|
|
117
|
+
exiting = true;
|
|
118
|
+
persist();
|
|
159
119
|
console.log(c.dim("\n " + t.bye));
|
|
120
|
+
tui.close(); // khôi phục terminal (raw mode/paste/stdout) trước khi thoát
|
|
160
121
|
process.exit(0);
|
|
161
122
|
}
|
|
123
|
+
// lần 1 → vũ trang, đếm 1.5s
|
|
162
124
|
sigintArmed = true;
|
|
163
125
|
console.log(c.dim("\n " + t.pressAgainToExit));
|
|
164
|
-
setTimeout(() =>
|
|
165
|
-
|
|
166
|
-
|
|
126
|
+
sigintTimer = setTimeout(() => {
|
|
127
|
+
sigintArmed = false;
|
|
128
|
+
sigintTimer = null;
|
|
129
|
+
}, 1500);
|
|
130
|
+
if (!closed) tui.setPrompt(promptStr(false));
|
|
131
|
+
}
|
|
132
|
+
process.on("SIGINT", interrupt);
|
|
167
133
|
|
|
168
134
|
// Đừng để một lỗi bất ngờ làm "tự động tắt" CLI. Nguyên nhân hay gặp:
|
|
169
135
|
// tiến trình cập nhật nền (spawn npm) phát sự kiện 'error' không ai bắt,
|
|
@@ -172,11 +138,11 @@ export async function startRepl(opts = {}) {
|
|
|
172
138
|
process.on("uncaughtException", (err) => {
|
|
173
139
|
if (abort) { abort.abort(); abort = null; }
|
|
174
140
|
console.log(c.err("\n ✗ lỗi: " + (err?.message || err)));
|
|
175
|
-
if (!closed)
|
|
141
|
+
if (!closed) tui.setPrompt(promptStr(false));
|
|
176
142
|
});
|
|
177
143
|
process.on("unhandledRejection", (err) => {
|
|
178
144
|
console.log(c.err("\n ✗ lỗi nền: " + (err?.message || err)));
|
|
179
|
-
if (!closed)
|
|
145
|
+
if (!closed) tui.setPrompt(promptStr(false));
|
|
180
146
|
});
|
|
181
147
|
|
|
182
148
|
// ── phiên (session): lưu lịch sử + resume giống Claude Code ───────────────
|
|
@@ -260,6 +226,7 @@ Cuối cùng: tóm tắt + danh sách sửa theo thứ tự ưu tiên. Thẳng t
|
|
|
260
226
|
persist();
|
|
261
227
|
}
|
|
262
228
|
|
|
229
|
+
tui.start();
|
|
263
230
|
banner();
|
|
264
231
|
printStatus(state);
|
|
265
232
|
if (!config.apiKey) console.log("\n" + c.tool(" " + t.notLoggedIn) + "\n");
|
|
@@ -310,9 +277,8 @@ Cuối cùng: tóm tắt + danh sách sửa theo thứ tự ưu tiên. Thẳng t
|
|
|
310
277
|
if (pending.length) {
|
|
311
278
|
// Có tin xếp hàng → tự gửi câu kế tiếp (không chờ gõ).
|
|
312
279
|
input = (pending.shift() ?? "").trim();
|
|
313
|
-
if (process.stdin.isTTY && input) console.log(promptStr() + input);
|
|
314
280
|
} else {
|
|
315
|
-
const raw = await ask(promptStr());
|
|
281
|
+
const raw = await ask(promptStr(false));
|
|
316
282
|
if (raw == null) break; // stdin fully closed and drained
|
|
317
283
|
input = raw.trim();
|
|
318
284
|
}
|
|
@@ -333,7 +299,7 @@ Cuối cùng: tóm tắt + danh sách sửa theo thứ tự ưu tiên. Thẳng t
|
|
|
333
299
|
}
|
|
334
300
|
}
|
|
335
301
|
exiting = true;
|
|
336
|
-
|
|
302
|
+
tui.close();
|
|
337
303
|
process.exit(0);
|
|
338
304
|
|
|
339
305
|
// ── turn handler ─────────────────────────────────────────────────────────
|
|
@@ -343,27 +309,22 @@ Cuối cùng: tóm tắt + danh sách sửa theo thứ tự ưu tiên. Thẳng t
|
|
|
343
309
|
return;
|
|
344
310
|
}
|
|
345
311
|
abort = new AbortController();
|
|
346
|
-
//
|
|
347
|
-
// pause) để "nuốt" input khi quay. Trên Windows nó KHÔNG khôi phục sạch khi
|
|
348
|
-
// stop → stdin chết → prompt "cho phép?" hiện ra rồi event loop cạn → CLI tự
|
|
349
|
-
// out; và Ctrl+C không thành SIGINT (phải spam mới thoát). Tắt hẳn để ora
|
|
350
|
-
// đừng đụng stdin — readline tự quản.
|
|
351
|
-
const spinner = ora({ color: "magenta", spinner: "dots", discardStdin: false });
|
|
312
|
+
// spinner: status hiển thị qua tui (thanh dưới), tui tự animate khung xoay
|
|
352
313
|
const t0 = Date.now();
|
|
353
314
|
let timer = null;
|
|
354
315
|
const tick = (label) => {
|
|
355
316
|
const elapsed = ((Date.now() - t0) / 1000).toFixed(0);
|
|
356
|
-
|
|
317
|
+
tui.status(c.dim(`${label}… ${elapsed}s`));
|
|
357
318
|
};
|
|
358
319
|
const stopSpin = () => {
|
|
359
320
|
if (timer) {
|
|
360
321
|
clearInterval(timer);
|
|
361
322
|
timer = null;
|
|
362
323
|
}
|
|
363
|
-
|
|
324
|
+
tui.status(null);
|
|
364
325
|
};
|
|
365
326
|
const startSpin = (label) => {
|
|
366
|
-
|
|
327
|
+
// (tui hiện status khi tick gọi)
|
|
367
328
|
if (!timer) timer = setInterval(() => tick(label), 200);
|
|
368
329
|
};
|
|
369
330
|
|
|
@@ -378,7 +339,7 @@ Cuối cùng: tóm tắt + danh sách sửa theo thứ tự ưu tiên. Thẳng t
|
|
|
378
339
|
message: text,
|
|
379
340
|
signal: abort.signal,
|
|
380
341
|
onStatus: (s) => {
|
|
381
|
-
if (!printer.started)
|
|
342
|
+
if (!printer.started) tui.status(c.dim(" " + s));
|
|
382
343
|
},
|
|
383
344
|
onDelta: (d) => {
|
|
384
345
|
stopSpin();
|
|
@@ -392,6 +353,10 @@ Cuối cùng: tóm tắt + danh sách sửa theo thứ tự ưu tiên. Thẳng t
|
|
|
392
353
|
}
|
|
393
354
|
|
|
394
355
|
state.history.push({ role: "user", content: text });
|
|
356
|
+
if (process.stdin.isTTY && !state.steerHintShown) {
|
|
357
|
+
console.log(c.dim(" " + t.steerHint));
|
|
358
|
+
state.steerHintShown = true;
|
|
359
|
+
}
|
|
395
360
|
startSpin(t.thinking);
|
|
396
361
|
let printer = null;
|
|
397
362
|
|
|
@@ -400,6 +365,14 @@ Cuối cùng: tóm tắt + danh sách sửa theo thứ tự ưu tiên. Thẳng t
|
|
|
400
365
|
model: state.model.id,
|
|
401
366
|
signal: abort.signal,
|
|
402
367
|
onStatus: () => tick(t.thinking),
|
|
368
|
+
onSteer: () => {
|
|
369
|
+
if (!pending.length) return [];
|
|
370
|
+
const msgs = pending.splice(0);
|
|
371
|
+
stopSpin(); // in sạch dòng chèn rồi cho spinner chạy lại
|
|
372
|
+
for (const msg of msgs) console.log(c.user(" " + t.steerInject(truncate(msg, 70))));
|
|
373
|
+
startSpin(t.thinking);
|
|
374
|
+
return msgs;
|
|
375
|
+
},
|
|
403
376
|
onDelta: (ev) => {
|
|
404
377
|
if (ev.type === "step-start") {
|
|
405
378
|
printer = makeStreamPrinter(state.model.name, providerColor(state.model.provider));
|
|
@@ -450,14 +423,14 @@ Cuối cùng: tóm tắt + danh sách sửa theo thứ tự ưu tiên. Thẳng t
|
|
|
450
423
|
}
|
|
451
424
|
}
|
|
452
425
|
|
|
453
|
-
|
|
426
|
+
tui.status(c.dim(" " + t.running));
|
|
454
427
|
try {
|
|
455
428
|
const result = await runTool(name, input);
|
|
456
|
-
|
|
429
|
+
tui.status(null);
|
|
457
430
|
console.log(c.ok(" ✓ ") + c.dim(firstLine(result)));
|
|
458
431
|
return { allow: true, result };
|
|
459
432
|
} catch (err) {
|
|
460
|
-
|
|
433
|
+
tui.status(null);
|
|
461
434
|
console.log(c.err(" ✗ " + err.message));
|
|
462
435
|
return { allow: true, result: "ERROR: " + err.message };
|
|
463
436
|
}
|
|
@@ -537,7 +510,7 @@ Cuối cùng: tóm tắt + danh sách sửa theo thứ tự ưu tiên. Thẳng t
|
|
|
537
510
|
persist(); // giữ lại phiên cũ trên đĩa
|
|
538
511
|
state.history = [];
|
|
539
512
|
startFresh(); // phiên mới (phiên cũ vẫn resume được)
|
|
540
|
-
console.clear();
|
|
513
|
+
if (!tui.tty) console.clear();
|
|
541
514
|
banner();
|
|
542
515
|
printStatus(state);
|
|
543
516
|
console.log(c.dim(" " + t.ctxCleared + "\n"));
|
|
@@ -592,14 +565,14 @@ Cuối cùng: tóm tắt + danh sách sửa theo thứ tự ưu tiên. Thẳng t
|
|
|
592
565
|
|
|
593
566
|
async function showUsage() {
|
|
594
567
|
if (!config.apiKey) return console.log(c.tool(" " + t.notLoggedIn));
|
|
595
|
-
|
|
568
|
+
tui.status(c.dim(" ..."));
|
|
596
569
|
try {
|
|
597
570
|
const u = await usage();
|
|
598
|
-
|
|
571
|
+
tui.status(null);
|
|
599
572
|
if (!u.ok) return printError(new ApiError(t.errInvalidKey, { code: u.error }));
|
|
600
573
|
printUsage(u);
|
|
601
574
|
} catch (err) {
|
|
602
|
-
|
|
575
|
+
tui.status(null);
|
|
603
576
|
printError(err);
|
|
604
577
|
}
|
|
605
578
|
}
|
package/src/tui.js
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
// Bộ render kiểu TUI: thanh nhập GHIM ở đáy, output cuộn phía trên, không nhấp
|
|
2
|
+
// nháy. Hỗ trợ paste nhiều dòng → hiện "[pasted N lines]" nhưng GỬI toàn bộ nội
|
|
3
|
+
// dung. Hoạt động bằng cách "vá" process.stdout.write: mọi output (console.log,
|
|
4
|
+
// stream token, box…) tự được commit phía TRÊN thanh nhập — nên repl gần như
|
|
5
|
+
// không phải đổi chỗ in.
|
|
6
|
+
//
|
|
7
|
+
// Bật/tắt: TTY thật → chế độ giàu; không phải TTY hoặc NOOB_TUI=0 → chế độ "dumb"
|
|
8
|
+
// (đọc dòng đơn giản, in thẳng) để khỏi vỡ ở terminal lạ / pipe / CI.
|
|
9
|
+
import readline from "node:readline";
|
|
10
|
+
import { c } from "./ui.js";
|
|
11
|
+
|
|
12
|
+
const ESC = "\x1b";
|
|
13
|
+
const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
|
|
14
|
+
const visLen = (s) => s.replace(ANSI_RE, "").length;
|
|
15
|
+
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
16
|
+
|
|
17
|
+
export function createTui({ onLine, onInterrupt, onEOF, onShiftTab } = {}) {
|
|
18
|
+
const tty = !!(process.stdout.isTTY && process.stdin.isTTY) && process.env.NOOB_TUI !== "0";
|
|
19
|
+
const cols = () => process.stdout.columns || 80;
|
|
20
|
+
|
|
21
|
+
// ── chế độ DUMB (không TTY / tắt TUI): readline thường ───────────────────
|
|
22
|
+
if (!tty) {
|
|
23
|
+
let rl = null;
|
|
24
|
+
let waiter = null;
|
|
25
|
+
const queue = [];
|
|
26
|
+
let closed = false;
|
|
27
|
+
const build = () => {
|
|
28
|
+
const r = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: "" });
|
|
29
|
+
r.on("line", (line) => {
|
|
30
|
+
if (waiter) {
|
|
31
|
+
const w = waiter;
|
|
32
|
+
waiter = null;
|
|
33
|
+
w(line);
|
|
34
|
+
} else if (onLine) onLine(line);
|
|
35
|
+
else queue.push(line);
|
|
36
|
+
});
|
|
37
|
+
r.on("close", () => {
|
|
38
|
+
closed = true;
|
|
39
|
+
if (waiter) {
|
|
40
|
+
const w = waiter;
|
|
41
|
+
waiter = null;
|
|
42
|
+
w(null);
|
|
43
|
+
}
|
|
44
|
+
onEOF?.();
|
|
45
|
+
});
|
|
46
|
+
return r;
|
|
47
|
+
};
|
|
48
|
+
return {
|
|
49
|
+
tty: false,
|
|
50
|
+
start() {
|
|
51
|
+
rl = build();
|
|
52
|
+
process.on("SIGINT", () => onInterrupt?.());
|
|
53
|
+
},
|
|
54
|
+
print() {},
|
|
55
|
+
status() {},
|
|
56
|
+
setPrompt() {},
|
|
57
|
+
read() {
|
|
58
|
+
if (queue.length) return Promise.resolve(queue.shift());
|
|
59
|
+
if (closed) return Promise.resolve(null);
|
|
60
|
+
return new Promise((res) => (waiter = res));
|
|
61
|
+
},
|
|
62
|
+
close() {
|
|
63
|
+
try {
|
|
64
|
+
rl?.close();
|
|
65
|
+
} catch {}
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── chế độ GIÀU (TTY) ─────────────────────────────────────────────────────
|
|
71
|
+
const realWrite = process.stdout.write.bind(process.stdout);
|
|
72
|
+
const w = (s) => realWrite(s);
|
|
73
|
+
|
|
74
|
+
let liveOut = ""; // dòng output dở dang (chưa có '\n') hiện ngay trên thanh
|
|
75
|
+
let statusText = null; // text spinner khi đang nghĩ
|
|
76
|
+
let frame = 0;
|
|
77
|
+
let frameTimer = null;
|
|
78
|
+
let prevRows = 0;
|
|
79
|
+
let drawn = false;
|
|
80
|
+
|
|
81
|
+
let promptLabel = "";
|
|
82
|
+
let parts = []; // input: {type:'text',value} | {type:'paste',value,lines}
|
|
83
|
+
let waiter = null;
|
|
84
|
+
const queue = [];
|
|
85
|
+
|
|
86
|
+
// ----- input → chuỗi đầy đủ + bản hiển thị (paste = chip) -----
|
|
87
|
+
const fullText = () => parts.map((p) => p.value).join("");
|
|
88
|
+
const dispPlain = () => parts.map((p) => (p.type === "paste" ? `[pasted ${p.lines} lines]` : p.value)).join("");
|
|
89
|
+
const dispColored = () =>
|
|
90
|
+
parts.map((p) => (p.type === "paste" ? c.dim(`[pasted ${p.lines} lines]`) : p.value)).join("");
|
|
91
|
+
|
|
92
|
+
function renderBar() {
|
|
93
|
+
const budget = Math.max(4, cols() - visLen(promptLabel) - 1);
|
|
94
|
+
const plain = dispPlain();
|
|
95
|
+
if (plain.length <= budget) return promptLabel + dispColored();
|
|
96
|
+
return promptLabel + "…" + plain.slice(plain.length - (budget - 1)); // giữ đuôi (chỗ đang gõ)
|
|
97
|
+
}
|
|
98
|
+
function topRow() {
|
|
99
|
+
if (liveOut) return liveOut.slice(0, cols());
|
|
100
|
+
if (statusText) return c.dim(FRAMES[frame % FRAMES.length] + " ") + statusText;
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
function rows() {
|
|
104
|
+
const r = [];
|
|
105
|
+
const top = topRow();
|
|
106
|
+
if (top !== null) r.push(top);
|
|
107
|
+
r.push(renderBar());
|
|
108
|
+
return r;
|
|
109
|
+
}
|
|
110
|
+
function eraseSeq() {
|
|
111
|
+
if (!drawn) return "\r";
|
|
112
|
+
return "\r" + (prevRows > 1 ? `${ESC}[${prevRows - 1}A` : "") + `${ESC}[J`;
|
|
113
|
+
}
|
|
114
|
+
function draw() {
|
|
115
|
+
const rs = rows();
|
|
116
|
+
w(`${ESC}[?25l` + eraseSeq() + rs.join("\n") + `${ESC}[?25h`);
|
|
117
|
+
prevRows = rs.length;
|
|
118
|
+
drawn = true;
|
|
119
|
+
}
|
|
120
|
+
// In khối text VĨNH VIỄN phía trên thanh, rồi vẽ lại thanh.
|
|
121
|
+
function commit(block) {
|
|
122
|
+
const rs = rows();
|
|
123
|
+
let s = `${ESC}[?25l` + eraseSeq();
|
|
124
|
+
s += block;
|
|
125
|
+
if (!block.endsWith("\n")) s += "\n";
|
|
126
|
+
s += rs.join("\n") + `${ESC}[?25h`;
|
|
127
|
+
w(s);
|
|
128
|
+
prevRows = rs.length;
|
|
129
|
+
drawn = true;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Mọi stdout đi qua đây: tách dòng hoàn chỉnh → commit; phần dở → liveOut.
|
|
133
|
+
function feedOutput(text) {
|
|
134
|
+
let buf = liveOut + text;
|
|
135
|
+
let nl;
|
|
136
|
+
const done = [];
|
|
137
|
+
while ((nl = buf.indexOf("\n")) !== -1) {
|
|
138
|
+
done.push(buf.slice(0, nl));
|
|
139
|
+
buf = buf.slice(nl + 1);
|
|
140
|
+
}
|
|
141
|
+
liveOut = buf;
|
|
142
|
+
if (done.length) commit(done.join("\n"));
|
|
143
|
+
else draw();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ----- bàn phím: paste (bracketed) + heuristic chunk nhiều dòng -----
|
|
147
|
+
function pushText(str) {
|
|
148
|
+
if (!str) return;
|
|
149
|
+
const last = parts[parts.length - 1];
|
|
150
|
+
if (last && last.type === "text") last.value += str;
|
|
151
|
+
else parts.push({ type: "text", value: str });
|
|
152
|
+
}
|
|
153
|
+
function pushPaste(content) {
|
|
154
|
+
const lines = content.split("\n").length;
|
|
155
|
+
if (lines >= 2) parts.push({ type: "paste", value: content, lines });
|
|
156
|
+
else pushText(content); // paste 1 dòng = gõ thẳng
|
|
157
|
+
}
|
|
158
|
+
function backspace() {
|
|
159
|
+
const last = parts[parts.length - 1];
|
|
160
|
+
if (!last) return;
|
|
161
|
+
if (last.type === "paste") parts.pop();
|
|
162
|
+
else {
|
|
163
|
+
last.value = [...last.value].slice(0, -1).join("");
|
|
164
|
+
if (!last.value) parts.pop();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
function submit() {
|
|
168
|
+
const full = fullText();
|
|
169
|
+
const echo = promptLabel + dispColored();
|
|
170
|
+
parts = [];
|
|
171
|
+
commit(echo); // hiện lại dòng đã gõ (paste = chip) vào scrollback
|
|
172
|
+
draw();
|
|
173
|
+
if (waiter) {
|
|
174
|
+
const wr = waiter;
|
|
175
|
+
waiter = null;
|
|
176
|
+
wr(full);
|
|
177
|
+
} else if (onLine) onLine(full);
|
|
178
|
+
else queue.push(full);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let carry = "";
|
|
182
|
+
let inPaste = false;
|
|
183
|
+
let pasteAcc = "";
|
|
184
|
+
function feedKeys(data) {
|
|
185
|
+
let s = carry + data;
|
|
186
|
+
carry = "";
|
|
187
|
+
let i = 0;
|
|
188
|
+
while (i < s.length) {
|
|
189
|
+
if (inPaste) {
|
|
190
|
+
const end = s.indexOf(`${ESC}[201~`, i);
|
|
191
|
+
if (end === -1) {
|
|
192
|
+
// giữ lại tối đa 5 ký tự cuối phòng marker bị cắt ngang chunk
|
|
193
|
+
let safe = s.length;
|
|
194
|
+
for (let k = 1; k <= 5; k++) if (`${ESC}[201~`.startsWith(s.slice(s.length - k))) { safe = s.length - k; break; }
|
|
195
|
+
pasteAcc += s.slice(i, safe);
|
|
196
|
+
carry = s.slice(safe);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
pasteAcc += s.slice(i, end);
|
|
200
|
+
inPaste = false;
|
|
201
|
+
pushPaste(pasteAcc.replace(/\r\n/g, "\n").replace(/\r/g, "\n"));
|
|
202
|
+
pasteAcc = "";
|
|
203
|
+
i = end + 6;
|
|
204
|
+
draw();
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
const rest = s.slice(i);
|
|
208
|
+
if (`${ESC}[200~`.startsWith(rest) && rest.length < 6) { carry = rest; return; } // marker dở
|
|
209
|
+
if (rest.startsWith(`${ESC}[200~`)) { inPaste = true; pasteAcc = ""; i += 6; continue; }
|
|
210
|
+
const ch = s[i];
|
|
211
|
+
if (ch === ESC) {
|
|
212
|
+
// esc seq (mũi tên…) — bỏ qua ở v1
|
|
213
|
+
const m = rest.match(/^\x1b\[[0-9;]*[A-Za-z~]/) || rest.match(/^\x1bO[A-Za-z]/);
|
|
214
|
+
if (m) { i += m[0].length; continue; }
|
|
215
|
+
if (rest.length < 3) { carry = rest; return; } // esc dở
|
|
216
|
+
i += 1;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (ch === "\r" || ch === "\n") {
|
|
220
|
+
submit();
|
|
221
|
+
if (ch === "\r" && s[i + 1] === "\n") i++; // nuốt \n của \r\n
|
|
222
|
+
i++;
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
if (ch === "\x7f" || ch === "\b") { backspace(); draw(); i++; continue; }
|
|
226
|
+
if (ch === "\x03") { onInterrupt?.(); i++; continue; } // Ctrl+C
|
|
227
|
+
if (ch === "\x15") { parts = []; draw(); i++; continue; } // Ctrl+U xoá dòng
|
|
228
|
+
if (ch === "\x04") { if (!fullText()) onEOF?.(); i++; continue; } // Ctrl+D
|
|
229
|
+
if (ch === "\t") { i++; continue; } // tab thường — bỏ (shift+tab xử lý qua keypress)
|
|
230
|
+
if (ch < " ") { i++; continue; } // control khác — bỏ
|
|
231
|
+
// ký tự in được (kể cả UTF-8 vì stdin đã setEncoding utf8)
|
|
232
|
+
pushText(ch);
|
|
233
|
+
draw();
|
|
234
|
+
i++;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function onData(data) {
|
|
239
|
+
// Heuristic fallback cho terminal KHÔNG hỗ trợ bracketed-paste: một lần gõ
|
|
240
|
+
// tay không bao giờ tạo ra "xuống dòng GIỮA chuỗi". Vậy nếu cả cục có một
|
|
241
|
+
// newline KHÔNG nằm cuối (tức còn nội dung sau nó) → đó là PASTE nhiều dòng.
|
|
242
|
+
if (!inPaste && !data.includes(`${ESC}[200~`)) {
|
|
243
|
+
const norm = data.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
244
|
+
const firstNl = norm.indexOf("\n");
|
|
245
|
+
if (firstNl !== -1 && firstNl < norm.length - 1 && data.length > 1) {
|
|
246
|
+
const body = norm.endsWith("\n") ? norm.slice(0, -1) : norm; // bỏ 1 newline đuôi
|
|
247
|
+
pushPaste(body); // ≥2 dòng → chip; KHÔNG auto-submit (người dùng Enter để gửi)
|
|
248
|
+
draw();
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
feedKeys(data);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
tty: true,
|
|
257
|
+
start() {
|
|
258
|
+
process.stdin.setEncoding("utf8");
|
|
259
|
+
if (process.stdin.setRawMode) process.stdin.setRawMode(true);
|
|
260
|
+
process.stdin.resume();
|
|
261
|
+
w(`${ESC}[?2004h`); // bật bracketed paste
|
|
262
|
+
process.stdin.on("data", onData);
|
|
263
|
+
// shift+tab: dùng keypress decoder song song (chỉ để bắt phím này)
|
|
264
|
+
try {
|
|
265
|
+
readline.emitKeypressEvents(process.stdin);
|
|
266
|
+
process.stdin.on("keypress", (_s, key) => {
|
|
267
|
+
if (key && key.name === "tab" && key.shift) onShiftTab?.();
|
|
268
|
+
});
|
|
269
|
+
} catch {}
|
|
270
|
+
// vá stdout: mọi output → commit phía trên thanh
|
|
271
|
+
process.stdout.write = (chunk, enc, cb) => {
|
|
272
|
+
feedOutput(typeof chunk === "string" ? chunk : chunk.toString(typeof enc === "string" ? enc : "utf8"));
|
|
273
|
+
if (typeof enc === "function") enc();
|
|
274
|
+
else if (typeof cb === "function") cb();
|
|
275
|
+
return true;
|
|
276
|
+
};
|
|
277
|
+
frameTimer = setInterval(() => {
|
|
278
|
+
if (statusText && !liveOut) {
|
|
279
|
+
frame++;
|
|
280
|
+
draw();
|
|
281
|
+
}
|
|
282
|
+
}, 90);
|
|
283
|
+
draw();
|
|
284
|
+
},
|
|
285
|
+
print(s = "") {
|
|
286
|
+
feedOutput(s.endsWith("\n") ? s : s + "\n");
|
|
287
|
+
},
|
|
288
|
+
status(text) {
|
|
289
|
+
statusText = text || null;
|
|
290
|
+
draw();
|
|
291
|
+
},
|
|
292
|
+
setPrompt(label) {
|
|
293
|
+
promptLabel = label || "";
|
|
294
|
+
draw();
|
|
295
|
+
},
|
|
296
|
+
read(label) {
|
|
297
|
+
if (label != null) promptLabel = label;
|
|
298
|
+
if (queue.length) return Promise.resolve(queue.shift());
|
|
299
|
+
draw();
|
|
300
|
+
return new Promise((res) => (waiter = res));
|
|
301
|
+
},
|
|
302
|
+
close() {
|
|
303
|
+
try {
|
|
304
|
+
if (frameTimer) clearInterval(frameTimer);
|
|
305
|
+
process.stdout.write = realWrite;
|
|
306
|
+
w(`${ESC}[?2004l${ESC}[?25h\n`);
|
|
307
|
+
if (process.stdin.setRawMode) process.stdin.setRawMode(false);
|
|
308
|
+
process.stdin.pause();
|
|
309
|
+
} catch {}
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
}
|