@noobdemon/noob-cli 1.12.15 → 1.12.17
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/CHANGELOG.md +9 -0
- package/package.json +1 -1
- package/src/agent.js +28 -2
- package/src/api.js +4 -0
- package/src/repl/complete.js +1 -0
- package/src/repl/state.js +1 -0
- package/src/repl.js +62 -9
- package/src/tui.js +73 -7
- package/src/ui.js +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
Tất cả thay đổi đáng kể của `@noobdemon/noob-cli` được ghi vào file này.
|
|
4
4
|
|
|
5
|
+
## [1.12.17] - 2026-06-24
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- **Image input support** (`src/tui.js`, `src/repl.js`, `src/api.js`, `src/agent.js`, `worker/src/worker.js`): rich TTY trên Windows hỗ trợ `Alt+V` để lấy ảnh từ clipboard, hiện chip `[pasted image #1]`, rồi gửi kèm payload `image: "data:image/png;base64,..."` qua gateway. CLI cũng tự đính kèm ảnh đầu tiên khi user nhắc `@file.png|jpg|jpeg|webp|gif` (giới hạn 8MB). Worker validate data URL và forward `image` sang Railway (`{message, model, image}`); fallback cũ giữ `{message, model}`.
|
|
9
|
+
|
|
10
|
+
### Verified
|
|
11
|
+
- `npm test` 100/100 pass.
|
|
12
|
+
- Railway `/chat` smoke với `image` data URL base64 trả mô tả ảnh thành công.
|
|
13
|
+
|
|
5
14
|
## [1.12.15] - 2026-06-16
|
|
6
15
|
|
|
7
16
|
### Fixed
|
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -170,6 +170,25 @@ function goalBlock(goal) {
|
|
|
170
170
|
].join('\n');
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
+
// Mode hint (build/plan/compose) — chỉ NHẮC mềm, KHÔNG hard-gate tool. build =
|
|
174
|
+
// default (full, không thêm khối). plan/compose chèn 1 đoạn hướng dẫn nhẹ vào
|
|
175
|
+
// system prompt để model điều chỉnh phong cách làm việc.
|
|
176
|
+
function modeBlock(uiMode) {
|
|
177
|
+
if (uiMode === 'plan') {
|
|
178
|
+
return [
|
|
179
|
+
'# CHẾ ĐỘ PLAN',
|
|
180
|
+
'Ưu tiên phân tích & đọc code trước. Đề xuất kế hoạch rõ ràng TRƯỚC khi sửa file. Tránh edit_file/write_file/run_command (thao tác thay đổi) trừ khi user đã xác nhận hướng đi. Mục tiêu: hiểu sâu & lên kế hoạch, không vội thực thi.',
|
|
181
|
+
].join('\n');
|
|
182
|
+
}
|
|
183
|
+
if (uiMode === 'compose') {
|
|
184
|
+
return [
|
|
185
|
+
'# CHẾ ĐỘ COMPOSE',
|
|
186
|
+
'Làm việc theo workflow có cấu trúc: (1) làm rõ specs/yêu cầu → (2) lập kế hoạch → (3) thực thi từng bước → (4) báo cáo kết quả. Giữ kỷ luật từng giai đoạn, không nhảy cóc.',
|
|
187
|
+
].join('\n');
|
|
188
|
+
}
|
|
189
|
+
return '';
|
|
190
|
+
}
|
|
191
|
+
|
|
173
192
|
// Môi trường chạy thực: model cần biết OS + shell để emit lệnh ĐÚNG. Không có
|
|
174
193
|
// khối này, trên Windows model hay emit lệnh Unix (wc/ls/cat/grep) → run_command
|
|
175
194
|
// (PowerShell) báo lỗi.
|
|
@@ -417,7 +436,7 @@ function relTime(ts) {
|
|
|
417
436
|
// chèn ngay sau SYSTEM để model biết và dùng được.
|
|
418
437
|
// recentSessions: breadcrumbs các phiên trước cùng workspace (repl.js cung cấp)
|
|
419
438
|
// → chèn ngay sau memoryBlock() để model "thấy" lịch sử dù chưa /resume.
|
|
420
|
-
export function buildSystem(history, extraToolsDoc, goal, recentSessions) {
|
|
439
|
+
export function buildSystem(history, extraToolsDoc, goal, recentSessions, uiMode) {
|
|
421
440
|
const parts = [SYSTEM, '', memoryBlock()];
|
|
422
441
|
// Auto-active skills (frontmatter `auto: true`) — luôn ON, không cần slash.
|
|
423
442
|
// Đặt sau memoryBlock để model thấy convention skill TRƯỚC khi vào tool/goal.
|
|
@@ -427,6 +446,8 @@ export function buildSystem(history, extraToolsDoc, goal, recentSessions) {
|
|
|
427
446
|
parts.push('', recentSessionsBlock(recentSessions));
|
|
428
447
|
}
|
|
429
448
|
if (goal && goal.trim()) parts.push('', goalBlock(goal));
|
|
449
|
+
const mode = modeBlock(uiMode);
|
|
450
|
+
if (mode) parts.push('', mode);
|
|
430
451
|
if (extraToolsDoc) parts.push('', extraToolsDoc);
|
|
431
452
|
parts.push('', runtimeContext());
|
|
432
453
|
return parts.join('\n');
|
|
@@ -535,6 +556,7 @@ function extractJsonObject(s, from) {
|
|
|
535
556
|
export async function runAgent({
|
|
536
557
|
history,
|
|
537
558
|
model,
|
|
559
|
+
image,
|
|
538
560
|
signal,
|
|
539
561
|
onTool,
|
|
540
562
|
onStatus,
|
|
@@ -545,6 +567,7 @@ export async function runAgent({
|
|
|
545
567
|
goal,
|
|
546
568
|
recentSessions,
|
|
547
569
|
pendingTasks,
|
|
570
|
+
uiMode,
|
|
548
571
|
}) {
|
|
549
572
|
// [GỠ BUDGET 2026-06-06] Không còn token budget enforcement. Agent/loop/sub-agent
|
|
550
573
|
// chạy không giới hạn token. Dừng theo: GOAL đạt, <<LOOP_DONE>>, <<ULTRA_DONE>>,
|
|
@@ -573,7 +596,7 @@ export async function runAgent({
|
|
|
573
596
|
await maybeSummarize(history, { model, signal });
|
|
574
597
|
} catch {}
|
|
575
598
|
|
|
576
|
-
const system = buildSystem(history, extraToolsDoc, goal, recentSessions);
|
|
599
|
+
const system = buildSystem(history, extraToolsDoc, goal, recentSessions, uiMode);
|
|
577
600
|
const message = buildUserMessage(history);
|
|
578
601
|
tokenMeter?.addInput(countTokens(system) + countTokens(message));
|
|
579
602
|
tokenMeter?.setContext(tokenMeter.total);
|
|
@@ -586,6 +609,7 @@ export async function runAgent({
|
|
|
586
609
|
const { text, finishReason } = await streamWithRetry({
|
|
587
610
|
model,
|
|
588
611
|
message,
|
|
612
|
+
image,
|
|
589
613
|
system,
|
|
590
614
|
signal,
|
|
591
615
|
tokenMeter,
|
|
@@ -734,6 +758,7 @@ export async function runAgent({
|
|
|
734
758
|
async function streamWithRetry({
|
|
735
759
|
model,
|
|
736
760
|
message,
|
|
761
|
+
image,
|
|
737
762
|
system,
|
|
738
763
|
signal,
|
|
739
764
|
tokenMeter,
|
|
@@ -749,6 +774,7 @@ async function streamWithRetry({
|
|
|
749
774
|
mode: 'chat',
|
|
750
775
|
model,
|
|
751
776
|
message,
|
|
777
|
+
image,
|
|
752
778
|
system,
|
|
753
779
|
signal,
|
|
754
780
|
effort,
|
package/src/api.js
CHANGED
|
@@ -157,6 +157,7 @@ function hasUnclosedToolBlock(text) {
|
|
|
157
157
|
export async function stream({
|
|
158
158
|
mode = 'chat',
|
|
159
159
|
message,
|
|
160
|
+
image,
|
|
160
161
|
model,
|
|
161
162
|
system,
|
|
162
163
|
conversation,
|
|
@@ -186,6 +187,7 @@ export async function stream({
|
|
|
186
187
|
endpoint,
|
|
187
188
|
mode,
|
|
188
189
|
message: prompt,
|
|
190
|
+
image,
|
|
189
191
|
model,
|
|
190
192
|
system,
|
|
191
193
|
conversation,
|
|
@@ -262,6 +264,7 @@ async function streamOnce({
|
|
|
262
264
|
endpoint,
|
|
263
265
|
mode,
|
|
264
266
|
message,
|
|
267
|
+
image,
|
|
265
268
|
model,
|
|
266
269
|
system,
|
|
267
270
|
conversation,
|
|
@@ -278,6 +281,7 @@ async function streamOnce({
|
|
|
278
281
|
else if (mode === 'merge') body = { message };
|
|
279
282
|
else {
|
|
280
283
|
body = { message, model, remember: true, memoryToken: getMemoryToken() };
|
|
284
|
+
if (image) body.image = image;
|
|
281
285
|
if (system) body.customInstructions = system;
|
|
282
286
|
if (Array.isArray(conversation) && conversation.length) body.conversation = conversation;
|
|
283
287
|
if (effort) body.effort = effort;
|
package/src/repl/complete.js
CHANGED
|
@@ -22,6 +22,7 @@ export const SLASH = [
|
|
|
22
22
|
{ name: '/improve', desc: 'phân tích workspace & gợi ý tính năng cải thiện' },
|
|
23
23
|
{ name: '/ultra', desc: 'tự hành: tự nghĩ & làm nhiệm vụ' },
|
|
24
24
|
{ name: '/agent', desc: 'bật/tắt agent mode (spawn sub-agent)' },
|
|
25
|
+
{ name: '/mode', desc: 'build|plan|compose — đổi chế độ agent (Ctrl+T cycle)' },
|
|
25
26
|
{ name: '/goal', desc: 'đặt HARD GOAL — model phải hướng tới tới khi /goal clear' },
|
|
26
27
|
{ name: '/loop', desc: 'chạy task định kỳ (vd: /loop 5m kiểm tra log) · /loop stop để dừng' },
|
|
27
28
|
{ name: '/tokens', desc: 'xem số token đã dùng phiên này' },
|
package/src/repl/state.js
CHANGED
|
@@ -18,6 +18,7 @@ export function createState(opts = {}, config) {
|
|
|
18
18
|
return {
|
|
19
19
|
model: findModel(opts.model) || findModel(config.model) || findModel(DEFAULT_MODEL),
|
|
20
20
|
mode: 'chat', // chat | merge | search
|
|
21
|
+
agentUiMode: 'build', // build | plan | compose — UI hint mode (Ctrl+T / /mode). KHÔNG đụng `mode`.
|
|
21
22
|
history: [],
|
|
22
23
|
autoApprove: new Set(), // tool name → 'a' (always, phiên)
|
|
23
24
|
autoApproveTurn: new Set(), // tool name → 't' (this turn, reset sau mỗi runAgent)
|
package/src/repl.js
CHANGED
|
@@ -101,6 +101,24 @@ import {
|
|
|
101
101
|
} from './repl/utils.js';
|
|
102
102
|
import { createAgentDispatcher } from './repl/agent-dispatch.js';
|
|
103
103
|
import { createBgRegistry } from './workflow-bg.js';
|
|
104
|
+
|
|
105
|
+
const IMAGE_MIME = {
|
|
106
|
+
'.png': 'image/png',
|
|
107
|
+
'.jpg': 'image/jpeg',
|
|
108
|
+
'.jpeg': 'image/jpeg',
|
|
109
|
+
'.webp': 'image/webp',
|
|
110
|
+
'.gif': 'image/gif',
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
function imageDataUrl(file) {
|
|
114
|
+
if (!file) return null;
|
|
115
|
+
const mime = IMAGE_MIME[path.extname(file).toLowerCase()];
|
|
116
|
+
if (!mime) return null;
|
|
117
|
+
const abs = path.resolve(process.cwd(), file);
|
|
118
|
+
const stat = fs.statSync(abs);
|
|
119
|
+
if (stat.size > 8 * 1024 * 1024) throw new Error('Ảnh quá lớn (>8MB): ' + file);
|
|
120
|
+
return `data:${mime};base64,${fs.readFileSync(abs).toString('base64')}`;
|
|
121
|
+
}
|
|
104
122
|
export async function startRepl(opts = {}) {
|
|
105
123
|
const state = createState(opts, config);
|
|
106
124
|
const tokenMeter = new TokenMeter();
|
|
@@ -160,6 +178,14 @@ export async function startRepl(opts = {}) {
|
|
|
160
178
|
tui.print(state.yolo ? c.err(' ' + t.yoloOn) : c.ok(' ' + t.yoloOff));
|
|
161
179
|
tui.setPrompt(promptStr(false));
|
|
162
180
|
},
|
|
181
|
+
onCtrlT: () => {
|
|
182
|
+
// Cycle build → plan → compose. UI hint mode (chỉ system-prompt hint).
|
|
183
|
+
const order = ['build', 'plan', 'compose'];
|
|
184
|
+
const next = order[(order.indexOf(state.agentUiMode) + 1) % order.length];
|
|
185
|
+
state.agentUiMode = next;
|
|
186
|
+
tui.setAgentMode(next);
|
|
187
|
+
tui.print(c.accent(' ◆ mode: ') + next);
|
|
188
|
+
},
|
|
163
189
|
completer: completeInput,
|
|
164
190
|
});
|
|
165
191
|
|
|
@@ -338,6 +364,7 @@ export async function startRepl(opts = {}) {
|
|
|
338
364
|
state.history = s.history || [];
|
|
339
365
|
state.mode = 'chat';
|
|
340
366
|
state.goal = s.goal || null; // khôi phục HARD GOAL nếu phiên cũ có
|
|
367
|
+
tui.setGoal(state.goal || '');
|
|
341
368
|
if (s.model) {
|
|
342
369
|
const m = findModel(s.model);
|
|
343
370
|
if (m) state.model = m;
|
|
@@ -1318,6 +1345,8 @@ NGUYÊN TẮC:
|
|
|
1318
1345
|
}
|
|
1319
1346
|
|
|
1320
1347
|
tui.start();
|
|
1348
|
+
tui.setAgentMode(state.agentUiMode);
|
|
1349
|
+
tui.setGoal(state.goal || '');
|
|
1321
1350
|
banner();
|
|
1322
1351
|
printStatus(state);
|
|
1323
1352
|
// noob.md status line — cho user thấy model có memory gì (số dòng, rules/notes,
|
|
@@ -1442,23 +1471,25 @@ NGUYÊN TẮC:
|
|
|
1442
1471
|
let input;
|
|
1443
1472
|
if (pending.length) {
|
|
1444
1473
|
// Có tin xếp hàng → tự gửi câu kế tiếp (không chờ gõ).
|
|
1445
|
-
input = (pending.shift() ?? '').trim();
|
|
1474
|
+
input = { text: (pending.shift() ?? '').trim(), images: [] };
|
|
1446
1475
|
} else {
|
|
1447
1476
|
const raw = await ask(promptStr(false));
|
|
1448
1477
|
if (raw == null) break; // stdin fully closed and drained
|
|
1449
|
-
input = raw.trim();
|
|
1478
|
+
input = typeof raw === 'string' ? { text: raw.trim(), images: [] } : raw;
|
|
1479
|
+
input.text = String(input.text || '').trim();
|
|
1480
|
+
input.images = Array.isArray(input.images) ? input.images : [];
|
|
1450
1481
|
}
|
|
1451
|
-
if (!input) continue;
|
|
1482
|
+
if (!input.text) continue;
|
|
1452
1483
|
// Bọc cả lượt: một lỗi trong xử lý lệnh/agent không được phép thoát ra
|
|
1453
1484
|
// ngoài vòng lặp (sẽ rơi vào .catch ở bin/noob.js → process.exit(1) =
|
|
1454
1485
|
// "tự động tắt"). Bắt ở đây, in lỗi, rồi tiếp tục vòng lặp.
|
|
1455
1486
|
try {
|
|
1456
|
-
if (input.startsWith('/')) {
|
|
1457
|
-
const done = await command(input);
|
|
1487
|
+
if (input.text.startsWith('/')) {
|
|
1488
|
+
const done = await command(input.text);
|
|
1458
1489
|
if (done) break;
|
|
1459
1490
|
continue;
|
|
1460
1491
|
}
|
|
1461
|
-
await handle(input);
|
|
1492
|
+
await handle(input.text, { images: input.images });
|
|
1462
1493
|
persist(); // lưu sau mỗi lượt → resume được kể cả khi tắt đột ngột
|
|
1463
1494
|
} catch (err) {
|
|
1464
1495
|
printError(err);
|
|
@@ -1470,7 +1501,7 @@ NGUYÊN TẮC:
|
|
|
1470
1501
|
process.exit(0);
|
|
1471
1502
|
|
|
1472
1503
|
// ── turn handler ─────────────────────────────────────────────────────────
|
|
1473
|
-
async function handle(text) {
|
|
1504
|
+
async function handle(text, opts = {}) {
|
|
1474
1505
|
if (!config.apiKey) {
|
|
1475
1506
|
console.log(c.tool(' ' + t.notLoggedIn));
|
|
1476
1507
|
return;
|
|
@@ -1535,9 +1566,12 @@ NGUYÊN TẮC:
|
|
|
1535
1566
|
}
|
|
1536
1567
|
|
|
1537
1568
|
const files = mentionedFiles(text);
|
|
1569
|
+
const image =
|
|
1570
|
+
opts.images?.[0] ||
|
|
1571
|
+
imageDataUrl(files.find((f) => IMAGE_MIME[path.extname(f).toLowerCase()]));
|
|
1538
1572
|
const content = files.length
|
|
1539
1573
|
? text +
|
|
1540
|
-
`\n\n[File người dùng nhắc tới bằng @: ${files.join(', ')} — đọc bằng read_file nếu cần.]`
|
|
1574
|
+
`\n\n[File người dùng nhắc tới bằng @: ${files.join(', ')} — đọc bằng read_file nếu cần.${image ? ' Ảnh đầu tiên đã được đính kèm cho model vision.' : ''}]`
|
|
1541
1575
|
: text;
|
|
1542
1576
|
state.history.push({ role: 'user', content });
|
|
1543
1577
|
// Update terminal title với session name (trích từ message đầu).
|
|
@@ -1550,7 +1584,8 @@ NGUYÊN TẮC:
|
|
|
1550
1584
|
state.history,
|
|
1551
1585
|
state.agentMode ? spawnAgentToolsDoc(0) : '',
|
|
1552
1586
|
state.goal,
|
|
1553
|
-
sessions.list(5, process.cwd()).filter((s) => s.id !== session?.id)
|
|
1587
|
+
sessions.list(5, process.cwd()).filter((s) => s.id !== session?.id),
|
|
1588
|
+
state.agentUiMode
|
|
1554
1589
|
);
|
|
1555
1590
|
const userMessage = buildUserMessage(state.history);
|
|
1556
1591
|
tokenMeter.setContext(countTokens(systemPrompt) + countTokens(userMessage));
|
|
@@ -1580,11 +1615,13 @@ NGUYÊN TẮC:
|
|
|
1580
1615
|
const answer = await runAgent({
|
|
1581
1616
|
history: state.history,
|
|
1582
1617
|
model: state.model.id,
|
|
1618
|
+
image,
|
|
1583
1619
|
signal: abort.signal,
|
|
1584
1620
|
tokenMeter,
|
|
1585
1621
|
goal: state.goal,
|
|
1586
1622
|
recentSessions: sessions.list(5, process.cwd()).filter((s) => s.id !== session?.id),
|
|
1587
1623
|
extraToolsDoc: state.agentMode ? spawnAgentToolsDoc(0) : '',
|
|
1624
|
+
uiMode: state.agentUiMode,
|
|
1588
1625
|
// Pending tasks: todo items chưa hoàn thành từ lượt trước → model tiếp tục ngay.
|
|
1589
1626
|
pendingTasks: (state.todos || []).filter((t) => !t.done).map((t) => t.text),
|
|
1590
1627
|
onStatus: () => tick(t.thinking),
|
|
@@ -1874,6 +1911,20 @@ NGUYÊN TẮC:
|
|
|
1874
1911
|
);
|
|
1875
1912
|
break;
|
|
1876
1913
|
}
|
|
1914
|
+
case 'mode': {
|
|
1915
|
+
const v = arg.toLowerCase().trim();
|
|
1916
|
+
if (!v) {
|
|
1917
|
+
console.log(c.accent(' ◆ mode: ') + state.agentUiMode);
|
|
1918
|
+
console.log(c.dim(' cú pháp: /mode build|plan|compose · hoặc Ctrl+T để cycle'));
|
|
1919
|
+
} else if (v === 'build' || v === 'plan' || v === 'compose') {
|
|
1920
|
+
state.agentUiMode = v;
|
|
1921
|
+
tui.setAgentMode(v);
|
|
1922
|
+
console.log(c.accent(' ◆ mode: ') + v);
|
|
1923
|
+
} else {
|
|
1924
|
+
console.log(c.err(' mode không hợp lệ: ') + v + c.dim(' (chọn build|plan|compose)'));
|
|
1925
|
+
}
|
|
1926
|
+
break;
|
|
1927
|
+
}
|
|
1877
1928
|
case 'kg': {
|
|
1878
1929
|
// Knowledge graph CRUD — port từ mcp-knowledge-graph, lưu .noob/kg.jsonl.
|
|
1879
1930
|
// Sub-cmd: list, path, add, obs, link, unlink, unobs, get, search, rm.
|
|
@@ -2012,10 +2063,12 @@ NGUYÊN TẮC:
|
|
|
2012
2063
|
v.toLowerCase() === 'xoa'
|
|
2013
2064
|
) {
|
|
2014
2065
|
state.goal = null;
|
|
2066
|
+
tui.setGoal('');
|
|
2015
2067
|
console.log(c.dim(' đã xoá goal'));
|
|
2016
2068
|
persist();
|
|
2017
2069
|
} else {
|
|
2018
2070
|
state.goal = v;
|
|
2071
|
+
tui.setGoal(v);
|
|
2019
2072
|
console.log(c.accent(' 🎯 đã đặt goal: ') + v);
|
|
2020
2073
|
persist();
|
|
2021
2074
|
}
|
package/src/tui.js
CHANGED
|
@@ -7,11 +7,32 @@
|
|
|
7
7
|
// Bật/tắt: TTY thật → chế độ giàu; không phải TTY hoặc NOOB_TUI=0 → chế độ "dumb"
|
|
8
8
|
// (đọc dòng đơn giản, in thẳng) để khỏi vỡ ở terminal lạ / pipe / CI.
|
|
9
9
|
import readline from 'node:readline';
|
|
10
|
+
import { execFileSync } from 'node:child_process';
|
|
10
11
|
import { c } from './ui.js';
|
|
11
12
|
|
|
12
13
|
const ESC = '\x1b';
|
|
13
14
|
const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
|
|
14
15
|
const visLen = (s) => s.replace(ANSI_RE, '').length;
|
|
16
|
+
function readClipboardImageDataUrl() {
|
|
17
|
+
if (process.platform !== 'win32') return null;
|
|
18
|
+
try {
|
|
19
|
+
const script =
|
|
20
|
+
'Add-Type -AssemblyName System.Windows.Forms,System.Drawing;' +
|
|
21
|
+
'$img=[Windows.Forms.Clipboard]::GetImage();' +
|
|
22
|
+
'if($null -eq $img){exit 2};' +
|
|
23
|
+
'$ms=New-Object IO.MemoryStream;' +
|
|
24
|
+
'$img.Save($ms,[Drawing.Imaging.ImageFormat]::Png);' +
|
|
25
|
+
'[Convert]::ToBase64String($ms.ToArray())';
|
|
26
|
+
const b64 = execFileSync('powershell.exe', ['-NoProfile', '-STA', '-Command', script], {
|
|
27
|
+
encoding: 'utf8',
|
|
28
|
+
timeout: 5000,
|
|
29
|
+
windowsHide: true,
|
|
30
|
+
}).trim();
|
|
31
|
+
return b64 ? `data:image/png;base64,${b64}` : null;
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
15
36
|
// Trả index trong `text` mà tại đó vị trí VISUAL đạt `targetVis`. Bỏ qua toàn
|
|
16
37
|
// bộ ANSI escape sequence khi đếm. Dùng cho soft-wrap khi text có màu.
|
|
17
38
|
function findVisPos(text, targetVis) {
|
|
@@ -107,7 +128,7 @@ function truncStr(s, max) {
|
|
|
107
128
|
return s.length > max ? s.slice(0, max - 1) + '…' : s;
|
|
108
129
|
}
|
|
109
130
|
|
|
110
|
-
export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer } = {}) {
|
|
131
|
+
export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, onCtrlT, completer } = {}) {
|
|
111
132
|
const tty = !!(process.stdout.isTTY && process.stdin.isTTY) && process.env.NOOB_TUI !== '0';
|
|
112
133
|
const cols = () => process.stdout.columns || 80;
|
|
113
134
|
|
|
@@ -154,6 +175,8 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
|
|
|
154
175
|
setMeta() {},
|
|
155
176
|
setTodos() {},
|
|
156
177
|
setUltra() {},
|
|
178
|
+
setAgentMode() {},
|
|
179
|
+
setGoal() {},
|
|
157
180
|
setBgAgents() {},
|
|
158
181
|
pushAgentLog() {},
|
|
159
182
|
clearAgentLog() {},
|
|
@@ -186,6 +209,8 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
|
|
|
186
209
|
let todos = []; // [{text, done}] — danh sách todo đang chạy, repl parse từ model output
|
|
187
210
|
let ultra = false; // ULTRA mode đang bật? → segment trong status bar
|
|
188
211
|
let bgAgents = 0; // số sub-agent / workflow chạy nền → segment + gợi ý Ctrl+O
|
|
212
|
+
let agentMode = ''; // build | plan | compose — UI hint mode → segment status bar (Ctrl+T / /mode)
|
|
213
|
+
let goalText = ''; // HARD GOAL hiện tại (/goal) → segment 🎯 status bar (truncate)
|
|
189
214
|
// Ring buffer log sub-agent nền: KHÔNG in ra UI chính, chỉ vào đây. Ctrl+O mở
|
|
190
215
|
// overlay xem. Mỗi entry: chuỗi đã format (có thể kèm màu). Giữ tối đa MAX_LOG.
|
|
191
216
|
const MAX_AGENT_LOG = 500;
|
|
@@ -203,6 +228,7 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
|
|
|
203
228
|
// vị trí con trỏ → hỗ trợ ←/→/Home/End/Delete, chèn & xoá GIỮA dòng.
|
|
204
229
|
let cells = [];
|
|
205
230
|
let cur = 0;
|
|
231
|
+
let imageSeq = 0;
|
|
206
232
|
let waiter = null;
|
|
207
233
|
const queue = [];
|
|
208
234
|
|
|
@@ -250,8 +276,13 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
|
|
|
250
276
|
? firstLine.slice(0, PASTE_PREVIEW_MAX - 1) + '…'
|
|
251
277
|
: firstLine;
|
|
252
278
|
};
|
|
253
|
-
const
|
|
279
|
+
const imageLabel = (x) => `[pasted image #${x.n}]`;
|
|
280
|
+
const cellStr = (x) => {
|
|
281
|
+
if (x.image) return imageLabel(x);
|
|
282
|
+
return x.paste !== undefined ? x.paste : x.c;
|
|
283
|
+
};
|
|
254
284
|
const cellPlain = (x) => {
|
|
285
|
+
if (x.image) return imageLabel(x);
|
|
255
286
|
if (x.paste === undefined) return x.c;
|
|
256
287
|
const preview = pastePreview(x.paste);
|
|
257
288
|
return preview ? `[pasted ${x.lines} lines: "${preview}"]` : `[pasted ${x.lines} lines]`;
|
|
@@ -260,6 +291,7 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
|
|
|
260
291
|
const coloredInput = () =>
|
|
261
292
|
cells
|
|
262
293
|
.map((x) => {
|
|
294
|
+
if (x.image) return c.dim(imageLabel(x));
|
|
263
295
|
if (x.paste === undefined) return x.c;
|
|
264
296
|
const preview = pastePreview(x.paste);
|
|
265
297
|
const label = preview
|
|
@@ -368,9 +400,11 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
|
|
|
368
400
|
const spin = FRAMES[frame % FRAMES.length];
|
|
369
401
|
// Badge ghép vào mọi dòng status: ULTRA bật/tắt + số sub-agent nền (gợi ý
|
|
370
402
|
// Ctrl+O xem log). Reset ANSI từng đoạn (chalk tự đóng) → không bleed.
|
|
403
|
+
const modeBadge = agentMode ? c.tool(`◆ ${agentMode}`) : '';
|
|
404
|
+
const goalBadge = goalText ? c.accent('🎯 ') + c.dim(truncStr(goalText, 28)) : '';
|
|
371
405
|
const ultraBadge = ultra ? c.err('🚀 ULTRA') : '';
|
|
372
406
|
const bgBadge = bgAgents ? c.accent(`⊕ ${bgAgents} nền`) + c.dim(' (Ctrl+O)') : '';
|
|
373
|
-
const badges = [ultraBadge, bgBadge].filter(Boolean).join(c.dim(' · '));
|
|
407
|
+
const badges = [modeBadge, goalBadge, ultraBadge, bgBadge].filter(Boolean).join(c.dim(' · '));
|
|
374
408
|
const badgeTail = badges ? c.dim(' · ') + badges : '';
|
|
375
409
|
// Todo progress: 1 dòng gọn dùng chung (📋 done/total bar pct%). Dựng SẴN ở
|
|
376
410
|
// đây để ghép vào status line theo trạng thái — KHÔNG return sớm nuốt status.
|
|
@@ -562,6 +596,18 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
|
|
|
562
596
|
histPos = null;
|
|
563
597
|
} else pushText(content); // paste 1 dòng = gõ thẳng
|
|
564
598
|
}
|
|
599
|
+
function pushImage(image) {
|
|
600
|
+
imageSeq += 1;
|
|
601
|
+
cells.splice(cur, 0, { image, n: imageSeq });
|
|
602
|
+
cur += 1;
|
|
603
|
+
histPos = null;
|
|
604
|
+
}
|
|
605
|
+
function pasteClipboardImage() {
|
|
606
|
+
const image = readClipboardImageDataUrl();
|
|
607
|
+
if (image) pushImage(image);
|
|
608
|
+
else w('\x07');
|
|
609
|
+
draw();
|
|
610
|
+
}
|
|
565
611
|
function backspace() {
|
|
566
612
|
if (cur > 0) {
|
|
567
613
|
cells.splice(cur - 1, 1);
|
|
@@ -576,7 +622,8 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
|
|
|
576
622
|
}
|
|
577
623
|
}
|
|
578
624
|
// null = ô dán (coi như ranh giới từ); ngược lại trả ký tự của ô.
|
|
579
|
-
const charAt = (k) =>
|
|
625
|
+
const charAt = (k) =>
|
|
626
|
+
cells[k] && cells[k].paste === undefined && !cells[k].image ? cells[k].c : null;
|
|
580
627
|
function moveWordLeft() {
|
|
581
628
|
while (cur > 0 && charAt(cur - 1) === ' ') cur -= 1;
|
|
582
629
|
if (cur > 0 && charAt(cur - 1) === null) return void (cur -= 1); // qua 1 chip
|
|
@@ -633,6 +680,7 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
|
|
|
633
680
|
|
|
634
681
|
function submit() {
|
|
635
682
|
const full = fullText();
|
|
683
|
+
const images = cells.filter((x) => x.image).map((x) => x.image);
|
|
636
684
|
const echo = echoUserBlock();
|
|
637
685
|
if (full.trim() && submitHistory[submitHistory.length - 1] !== full) {
|
|
638
686
|
submitHistory.push(full);
|
|
@@ -648,9 +696,9 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
|
|
|
648
696
|
if (waiter) {
|
|
649
697
|
const wr = waiter;
|
|
650
698
|
waiter = null;
|
|
651
|
-
wr(full);
|
|
652
|
-
} else if (onLine) onLine(full);
|
|
653
|
-
else queue.push(full);
|
|
699
|
+
wr(images.length ? { text: full, images } : full);
|
|
700
|
+
} else if (onLine) onLine(images.length ? { text: full, images } : full);
|
|
701
|
+
else queue.push(images.length ? { text: full, images } : full);
|
|
654
702
|
}
|
|
655
703
|
|
|
656
704
|
let carry = '';
|
|
@@ -696,6 +744,11 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
|
|
|
696
744
|
}
|
|
697
745
|
const ch = s[i];
|
|
698
746
|
if (ch === ESC) {
|
|
747
|
+
if (rest[1] === 'v' || rest[1] === 'V') {
|
|
748
|
+
pasteClipboardImage();
|
|
749
|
+
i += 2;
|
|
750
|
+
continue;
|
|
751
|
+
}
|
|
699
752
|
// Một chuỗi CSI hoàn chỉnh: \x1b[ … <chữ/~> hoặc SS3: \x1bO<chữ>.
|
|
700
753
|
const csi = rest.match(/^\x1b\[[0-9;]*[~A-Za-z]/) || rest.match(/^\x1bO[A-Za-z]/);
|
|
701
754
|
if (!csi) {
|
|
@@ -800,6 +853,11 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
|
|
|
800
853
|
i++;
|
|
801
854
|
continue;
|
|
802
855
|
} // Ctrl+O: bật/tắt overlay log sub-agent nền
|
|
856
|
+
if (ch === '\x14') {
|
|
857
|
+
onCtrlT?.();
|
|
858
|
+
i++;
|
|
859
|
+
continue;
|
|
860
|
+
} // Ctrl+T: cycle build/plan/compose mode
|
|
803
861
|
if (ch === '\x01') {
|
|
804
862
|
cur = 0;
|
|
805
863
|
draw();
|
|
@@ -933,6 +991,14 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, completer }
|
|
|
933
991
|
ultra = !!on;
|
|
934
992
|
draw();
|
|
935
993
|
},
|
|
994
|
+
setAgentMode(mode) {
|
|
995
|
+
agentMode = mode || '';
|
|
996
|
+
draw();
|
|
997
|
+
},
|
|
998
|
+
setGoal(text) {
|
|
999
|
+
goalText = text || '';
|
|
1000
|
+
draw();
|
|
1001
|
+
},
|
|
936
1002
|
setBgAgents(n) {
|
|
937
1003
|
bgAgents = Math.max(0, n | 0);
|
|
938
1004
|
draw();
|
package/src/ui.js
CHANGED