@noobdemon/noob-cli 1.13.1 → 1.13.2
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 +14 -0
- package/README.md +139 -106
- package/package.json +3 -2
- package/src/agent.js +4 -0
- package/src/api.js +11 -1
- package/src/diff.js +40 -6
- package/src/i18n.js +0 -14
- package/src/repl/commands/goal.js +37 -0
- package/src/repl/commands/kg.js +147 -0
- package/src/repl.js +36 -147
- package/src/tools.js +11 -1
- package/src/tui.js +128 -29
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
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.13.2] - 2026-06-28
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- **Hỗ trợ nhiều ảnh trong một tin nhắn**: paste hoặc nhắc nhiều ảnh `@file` trong cùng một lượt — tất cả được đính kèm cho mô hình vision, mỗi ảnh là một phần riêng. Trước đây chỉ ảnh đầu tiên được gửi đi.
|
|
9
|
+
- **Smoke test chống README drift** (`scripts/smoke-readme-drift.mjs`): đối chiếu README với danh mục mô hình + tool thật — fail nếu README quảng cáo số mô hình lớn hơn thực tế hoặc thiếu tool. 20/20 pass.
|
|
10
|
+
- **Gợi ý trang động cho `web_fetch`** (`src/tools.js`): khi tải một trang nhiều HTML nhưng bóc ra rất ít chữ (thường là trang render bằng JS), trả thêm gợi ý thử chế độ `raw` thay vì để mô hình tưởng trang trống. 16/16 pass.
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
- **README viết lại khớp thực tế**: số lượng mô hình, danh sách tool và lệnh trong phiên, cấu trúc thư mục `src/` đều cập nhật cho đúng với mã hiện tại.
|
|
14
|
+
|
|
15
|
+
### Internal
|
|
16
|
+
- **Tách bớt `src/repl.js`** (`src/repl/commands/kg.js` + `src/repl/commands/goal.js`): khối `/kg` và `/goal` chuyển thành hàm riêng nhận dependency injected, không truy cập closure `startRepl`. Verify: import sạch, `smoke-kg` 45/45, `npm test` 109/109.
|
|
17
|
+
- **Luồng ảnh dạng mảng** (`src/api.js` + `src/agent.js` + `src/repl.js`): tham số `images` (mảng) luồng xuyên `runAgent → streamWithRetry → stream → streamOnce`, giữ `image` đơn làm alias tương thích ngược.
|
|
18
|
+
|
|
5
19
|
## [1.13.1] - 2026-06-28
|
|
6
20
|
|
|
7
21
|
### Fixed
|
package/README.md
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# noob — agentic coding CLI
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
**Noob Demon**
|
|
5
|
-
|
|
3
|
+
Một coding agent kiểu Claude Code sống trong terminal, giao tiếp **tiếng Việt**,
|
|
4
|
+
chạy qua gateway **Noob Demon** với 3 mô hình flagship (Claude Opus 4.8, GPT-5.5,
|
|
5
|
+
DeepSeek V4 Pro).
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
Nó đọc & sửa file, chạy lệnh shell, tự lặp để hoàn thành tác vụ — xin phép trước
|
|
8
|
+
mọi thao tác có rủi ro — tất cả trong một giao diện terminal gọn gàng.
|
|
9
9
|
|
|
10
10
|
```
|
|
11
11
|
███╗ ██╗ ██████╗ ██████╗ ██████╗
|
|
@@ -21,15 +21,15 @@ permission before anything destructive — all inside a polished terminal UI.
|
|
|
21
21
|
```bash
|
|
22
22
|
cd "noob cli"
|
|
23
23
|
npm install
|
|
24
|
-
npm link #
|
|
24
|
+
npm link # tùy chọn: cho phép gõ `noob` toàn cục
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
Yêu cầu Node.js ≥ 18 (test trên 22).
|
|
28
28
|
|
|
29
|
-
## Xác thực & gói cước
|
|
29
|
+
## Xác thực & gói cước
|
|
30
30
|
|
|
31
|
-
noob đi qua một **gateway** (Cloudflare Worker
|
|
32
|
-
|
|
31
|
+
noob đi qua một **gateway** (Cloudflare Worker) để ẩn backend thật và quản lý API
|
|
32
|
+
key. Bạn cần một API key để dùng:
|
|
33
33
|
|
|
34
34
|
```bash
|
|
35
35
|
noob login nk_xxx_xxxxxxxx # đăng nhập, key lưu ở ~/.noob/config.json
|
|
@@ -50,51 +50,71 @@ Trong phiên: `/login <key>`, `/usage`, `/logout`.
|
|
|
50
50
|
|
|
51
51
|
> Mỗi lệnh gọi mô hình (kể cả từng bước tool trong một tác vụ) tính là 1 request.
|
|
52
52
|
|
|
53
|
-
Cấp/huỷ key (admin) — trong repo gateway `worker/`:
|
|
54
|
-
|
|
55
|
-
```bash
|
|
56
|
-
node scripts/admin.mjs create pro "khách A"
|
|
57
|
-
node scripts/admin.mjs list
|
|
58
|
-
node scripts/admin.mjs revoke nk_pro_xxx
|
|
59
|
-
```
|
|
60
|
-
|
|
61
53
|
## Use
|
|
62
54
|
|
|
63
55
|
```bash
|
|
64
|
-
noob #
|
|
65
|
-
noob "add input validation to api.js" #
|
|
66
|
-
noob -m gateway-claude-opus-4-8 #
|
|
67
|
-
noob --yolo #
|
|
56
|
+
noob # phiên tương tác
|
|
57
|
+
noob "add input validation to api.js" # bắt đầu kèm yêu cầu
|
|
58
|
+
noob -m gateway-claude-opus-4-8 # chọn mô hình
|
|
59
|
+
noob --yolo # tự duyệt edit & lệnh
|
|
68
60
|
```
|
|
69
61
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
|
78
|
-
|
|
|
79
|
-
| `/
|
|
80
|
-
| `/
|
|
81
|
-
| `/
|
|
82
|
-
| `/
|
|
83
|
-
| `/
|
|
84
|
-
| `/
|
|
62
|
+
Không `npm link` thì chạy `node bin/noob.js …`.
|
|
63
|
+
|
|
64
|
+
Cờ dòng lệnh: `--yolo`, `--ultra`, `--model <name>`, `--continue`, `--resume`,
|
|
65
|
+
`--insecure-tls`.
|
|
66
|
+
|
|
67
|
+
### Lệnh trong phiên
|
|
68
|
+
|
|
69
|
+
| Lệnh | Tác dụng |
|
|
70
|
+
| ----------------- | ------------------------------------------------------------- |
|
|
71
|
+
| `/help` | danh sách lệnh |
|
|
72
|
+
| `/model [name]` | đổi mô hình (fuzzy match), hoặc liệt kê tất cả |
|
|
73
|
+
| `/models` | liệt kê mọi mô hình theo provider |
|
|
74
|
+
| `/merge` | bật/tắt **Merge AI** (tổng hợp đa mô hình) |
|
|
75
|
+
| `/search` | bật/tắt chế độ tìm web |
|
|
76
|
+
| `/chat` | quay lại chế độ chat thường |
|
|
77
|
+
| `/agent on\|off` | bật/tắt agent mode (đẻ sub-agent song song/tuần tự/phân cấp) |
|
|
78
|
+
| `/mode` | build\|plan\|compose — đổi chế độ agent (Ctrl+T cycle) |
|
|
79
|
+
| `/goal [text]` | đặt HARD GOAL cho phiên · `/goal clear` để xoá |
|
|
80
|
+
| `/loop <iv> <task>` | chạy task định kỳ · `/loop stop` để dừng |
|
|
81
|
+
| `/ultra` | chế độ tự hành: model tự nghĩ & làm tới khi xong |
|
|
82
|
+
| `/workflow` | orchestrate workflow đa-agent (xem `/workflow help`) |
|
|
83
|
+
| `/kg` | knowledge graph của project (xem `/kg help`) |
|
|
84
|
+
| `/compact` | tóm tắt phiên ngay để gọn ngữ cảnh |
|
|
85
|
+
| `/tokens` | xem token đã dùng trong phiên |
|
|
86
|
+
| `/memory` | xem bộ nhớ `noob.md` · `/memory stats` xem kích thước |
|
|
87
|
+
| `/learn` | chưng cất bài học vào `noob.md` |
|
|
88
|
+
| `/init` | quét dự án & tạo `noob.md` |
|
|
89
|
+
| `/karpathy` | rà soát code theo nguyên tắc Karpathy |
|
|
90
|
+
| `/frontend-design`| skill thiết kế UI frontend chất lượng cao |
|
|
91
|
+
| `/improve` | phân tích workspace & gợi ý tính năng cải thiện |
|
|
92
|
+
| `/add-dir <path>` | thêm thư mục ngoài cwd vào phạm vi · `remove` để gỡ |
|
|
93
|
+
| `/cwd` | thư mục làm việc hiện tại |
|
|
94
|
+
| `/yolo` | bật/tắt tự duyệt file/lệnh (hoặc **Shift+Tab**) |
|
|
95
|
+
| `/auto-yolo` | lưu yolo làm mặc định mỗi lần chạy |
|
|
96
|
+
| `/clear` `/new` | xoá ngữ cảnh / phiên mới |
|
|
97
|
+
| `/continue` | tiếp tục phiên gần nhất |
|
|
98
|
+
| `/resume` | chọn & tiếp tục một phiên cũ |
|
|
99
|
+
| `/sessions` | liệt kê phiên đã lưu |
|
|
100
|
+
| `/status` | mô hình hiện tại + thư mục làm việc |
|
|
101
|
+
| `/version` | phiên bản |
|
|
102
|
+
| `/login` `/logout` `/usage` | xác thực & hạn mức |
|
|
103
|
+
| `/update` | tự cập nhật noob |
|
|
104
|
+
| `/exit` | thoát (Ctrl+C một lần = dừng turn, hai lần = thoát) |
|
|
85
105
|
|
|
86
106
|
## How it works
|
|
87
107
|
|
|
88
|
-
|
|
89
|
-
function-calling. noob
|
|
108
|
+
Gateway Noob Demon là một endpoint **stateless single-message** không có
|
|
109
|
+
function-calling gốc. noob phủ một lớp agent lên trên:
|
|
90
110
|
|
|
91
|
-
1.
|
|
92
|
-
|
|
93
|
-
2.
|
|
94
|
-
3. noob
|
|
95
|
-
|
|
111
|
+
1. Toàn bộ transcript (system prompt + history + kết quả tool) được serialize
|
|
112
|
+
thành một `message` rồi stream tới `/api/chat`.
|
|
113
|
+
2. Model trả về hoặc câu trả lời cuối, hoặc một khối ```tool JSON duy nhất.
|
|
114
|
+
3. noob parse tool call, xin phép nếu nó có rủi ro, thực thi, rồi feed kết quả
|
|
115
|
+
lại — lặp tới khi model trả lời không kèm tool block.
|
|
96
116
|
|
|
97
|
-
###
|
|
117
|
+
### Kiến trúc (tổng quan)
|
|
98
118
|
|
|
99
119
|
```mermaid
|
|
100
120
|
flowchart LR
|
|
@@ -104,8 +124,8 @@ flowchart LR
|
|
|
104
124
|
API -->|HTTPS stream| GW[(Noob Demon<br/>Gateway)]
|
|
105
125
|
GW -->|delta tokens| API
|
|
106
126
|
API -->|onDelta callbacks| Agent
|
|
107
|
-
Agent -->|parse tool block| Tools[tools.js<br/>read/write/edit/run]
|
|
108
|
-
Tools -->|fs / spawn| FS[(workspace)]
|
|
127
|
+
Agent -->|parse tool block| Tools[tools.js<br/>read/write/edit/run/fetch]
|
|
128
|
+
Tools -->|fs / spawn / http| FS[(workspace)]
|
|
109
129
|
Tools -->|result text| Agent
|
|
110
130
|
Agent -->|final answer| REPL
|
|
111
131
|
REPL -->|persist| Sessions[(~/.noob/sessions/)]
|
|
@@ -114,109 +134,122 @@ flowchart LR
|
|
|
114
134
|
Subagent -->|isolated context| API
|
|
115
135
|
```
|
|
116
136
|
|
|
117
|
-
###
|
|
137
|
+
### Tool agent gọi được
|
|
118
138
|
|
|
119
|
-
|
|
139
|
+
Đọc/khám phá (chạy tự do, không xin phép):
|
|
140
|
+
`read_file` · `list_dir` · `glob` · `grep` · `web_fetch`
|
|
120
141
|
|
|
121
|
-
|
|
122
|
-
|
|
142
|
+
Thao tác có rủi ro (xin phép `y`/`n`/`a`, thêm `t`=hết turn, `f`=file này khi
|
|
143
|
+
bật rich TTY):
|
|
144
|
+
`write_file` · `edit_file` · `run_command`
|
|
145
|
+
|
|
146
|
+
Quản lý tiến trình nền & điều phối:
|
|
147
|
+
`bg_output` · `kill_bg` · `write_todos` · `spawn_agent` / `spawn_agents` (khi
|
|
148
|
+
`/agent` bật) · `kg_search` / `kg_add` / `kg_link` / `kg_obs` (knowledge graph)
|
|
149
|
+
|
|
150
|
+
> `web_fetch` truy cập mạng (http/https) nhưng read-only nên không xin phép.
|
|
151
|
+
> Nó strip HTML bằng regex; với trang render bằng JS (SPA) sẽ trả về gần rỗng
|
|
152
|
+
> kèm gợi ý thử `raw:true`.
|
|
123
153
|
|
|
124
154
|
## Configuration
|
|
125
155
|
|
|
126
|
-
| Env var |
|
|
127
|
-
| --------------------- |
|
|
128
|
-
| `NOOB_API_BASE` |
|
|
129
|
-
| `NOOB_API_KEY` | API key (
|
|
130
|
-
| `NOOB_INSECURE_TLS=1` |
|
|
156
|
+
| Env var | Tác dụng |
|
|
157
|
+
| --------------------- | ------------------------------------------------------------------ |
|
|
158
|
+
| `NOOB_API_BASE` | ghi đè URL gateway |
|
|
159
|
+
| `NOOB_API_KEY` | API key (ghi đè `~/.noob/config.json`) |
|
|
160
|
+
| `NOOB_INSECURE_TLS=1` | tắt verify TLS — **giải pháp cuối** cho máy sau proxy chặn TLS. |
|
|
131
161
|
|
|
132
162
|
## Model compatibility
|
|
133
163
|
|
|
134
|
-
|
|
135
|
-
|
|
164
|
+
Agent điều khiển tool qua một text protocol (gateway không có function-calling
|
|
165
|
+
gốc). Các model khác nhau về mức độ sẵn lòng tuân theo:
|
|
136
166
|
|
|
137
|
-
| Provider | Agentic tools |
|
|
167
|
+
| Provider | Agentic tools | Ghi chú |
|
|
138
168
|
| ---------------------- | ---------------- | ---------------------------------------- |
|
|
139
|
-
| **Anthropic** (Claude) | ✅
|
|
140
|
-
| **DeepSeek** | ✅
|
|
141
|
-
| OpenAI (GPT
|
|
142
|
-
| Google (Gemini) | ⚠️ often refuses | same |
|
|
169
|
+
| **Anthropic** (Claude) | ✅ tốt nhất | mặc định — `gateway-claude-opus-4-8` |
|
|
170
|
+
| **DeepSeek** | ✅ chạy tốt | lựa chọn thay thế tốt |
|
|
171
|
+
| OpenAI (GPT) | ⚠️ hay từ chối | trả "I can't access your filesystem" |
|
|
143
172
|
|
|
144
|
-
|
|
145
|
-
|
|
173
|
+
Dùng Claude hoặc DeepSeek cho edit file & chạy lệnh. Mọi model đều ổn cho chat
|
|
174
|
+
thường, `/merge`, và `/search`.
|
|
146
175
|
|
|
147
176
|
## Project structure
|
|
148
177
|
|
|
149
178
|
```
|
|
150
179
|
src/
|
|
151
|
-
├── api.js # gateway client + stream parser +
|
|
152
|
-
├── agent.js # tool-loop driver,
|
|
153
|
-
├── tools.js # read/write/edit/list_dir/glob/grep/run_command
|
|
154
|
-
├── repl.js # input loop, slash commands, session state
|
|
180
|
+
├── api.js # gateway client + stream parser + quota warning
|
|
181
|
+
├── agent.js # tool-loop driver, SYSTEM prompt, summarization
|
|
182
|
+
├── tools.js # read/write/edit/list_dir/glob/grep/run_command/web_fetch/bg/kg
|
|
183
|
+
├── repl.js # input loop, slash commands, session state
|
|
155
184
|
├── repl/
|
|
156
|
-
│ ├──
|
|
157
|
-
│ ├──
|
|
158
|
-
│ ├──
|
|
159
|
-
│
|
|
185
|
+
│ ├── agent-dispatch.js # spawn_agent + write_todos dispatcher
|
|
186
|
+
│ ├── complete.js # SLASH catalog + autocomplete
|
|
187
|
+
│ ├── permission.js # askPermission (y/n/a/t/f scopes)
|
|
188
|
+
│ ├── state.js # createState — session state shape
|
|
189
|
+
│ ├── stream-printer.js # render stream theo dòng (heading/table/code)
|
|
190
|
+
│ ├── todos.js # parseTodosFromHistory (pure)
|
|
191
|
+
│ ├── ultra.js # ULTRA mode prompt templates
|
|
192
|
+
│ ├── workflow-commands.js # /workflow help/list/load/delete (pure)
|
|
193
|
+
│ └── commands/ # nhóm slash command tách rời
|
|
160
194
|
├── subagent.js # spawn_agent / spawn_agents — sub-agent isolation
|
|
161
|
-
├──
|
|
162
|
-
├──
|
|
195
|
+
├── workflow-bg.js # workflow chạy nền
|
|
196
|
+
├── workflow-runs.js # lưu/khôi phục lần chạy workflow
|
|
197
|
+
├── workflows.js # ~/.noob/workflows/ — workflow user lưu
|
|
198
|
+
├── workflows-builtin.js # workflow ship-sẵn (deep-research, verify-claims, triage)
|
|
199
|
+
├── kg.js # knowledge graph (.noob/kg.jsonl per project)
|
|
200
|
+
├── tui.js # terminal UI (input, status bar, todo progress)
|
|
201
|
+
├── ui.js # color, markdown renderer, banner
|
|
202
|
+
├── diff.js # render diff khi edit_file
|
|
163
203
|
├── config.js # ~/.noob/config.json (api key, gateway, model)
|
|
164
204
|
├── sessions.js # ~/.noob/sessions/ — save/list/resume
|
|
165
|
-
├── skills.js #
|
|
166
|
-
├── workflows.js # ~/.noob/workflows/ — user-saved workflows
|
|
167
|
-
├── workflows-builtin.js # ship-with-binary workflows (deep-research, etc)
|
|
205
|
+
├── skills.js # skill loader (cwd → package)
|
|
168
206
|
├── memory.js # noob.md per-project agent memory
|
|
169
|
-
├── tokens.js # local token counter (
|
|
170
|
-
├── models.js #
|
|
171
|
-
├── i18n.js #
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
tests/ # vitest unit tests (96 tests)
|
|
177
|
-
scripts/ # check-imports, notify-discord, release.ps1
|
|
207
|
+
├── tokens.js # local token counter (gpt-tokenizer)
|
|
208
|
+
├── models.js # 3-model flagship catalog + fuzzy resolver
|
|
209
|
+
├── i18n.js # bảng chuỗi tiếng Việt
|
|
210
|
+
└── update.js # `noob update` self-updater
|
|
211
|
+
|
|
212
|
+
tests/ # vitest unit tests
|
|
213
|
+
scripts/ # smoke tests + notify-discord
|
|
178
214
|
```
|
|
179
215
|
|
|
180
216
|
## Development
|
|
181
217
|
|
|
182
218
|
```bash
|
|
183
|
-
npm test #
|
|
184
|
-
npm run lint # eslint
|
|
219
|
+
npm test # chạy vitest unit tests
|
|
220
|
+
npm run lint # eslint
|
|
185
221
|
npm run lint:fix # eslint --fix
|
|
186
222
|
npm run format # prettier --write
|
|
187
223
|
npm run format:check # prettier --check
|
|
188
|
-
npm run check # lint + format:check + test (pre-commit
|
|
224
|
+
npm run check # lint + format:check + test (cổng pre-commit)
|
|
189
225
|
```
|
|
190
226
|
|
|
191
227
|
Husky chạy `lint-staged` + `npm test` trước mỗi commit. Bỏ qua bằng
|
|
192
228
|
`git commit --no-verify` nếu thực sự cần (vd commit WIP).
|
|
193
229
|
|
|
230
|
+
Ngoài unit test, `scripts/` chứa nhiều smoke test chạy bằng
|
|
231
|
+
`node scripts/smoke-<name>.mjs` (offline, không cần gateway).
|
|
232
|
+
|
|
194
233
|
## Troubleshooting
|
|
195
234
|
|
|
196
235
|
**"CLI tự thoát sau khi hỏi quyền"** — thường là readline trên Windows phát sự
|
|
197
|
-
kiện `close` từ tiến trình con
|
|
198
|
-
|
|
236
|
+
kiện `close` từ tiến trình con. Đã có workaround trong `repl.js`. Nếu còn xảy ra,
|
|
237
|
+
mở issue kèm OS + Node version.
|
|
199
238
|
|
|
200
|
-
**"TLS error / certificate"** — máy
|
|
239
|
+
**"TLS error / certificate"** — máy sau proxy chặn TLS (Zscaler, Cisco
|
|
201
240
|
Umbrella…). Ưu tiên add CA của proxy vào trust store. Cuối cùng mới dùng
|
|
202
241
|
`NOOB_INSECURE_TLS=1` (tắt verify TLS toàn process, MITM-vulnerable).
|
|
203
242
|
|
|
204
|
-
**"Model OpenAI
|
|
205
|
-
|
|
206
|
-
`/model deepseek-v3` cho tasks cần tool.
|
|
207
|
-
|
|
208
|
-
**"Token count có vẻ sai"** — `tokens.js` chọn encoder theo model id
|
|
209
|
-
(`o200k_base` cho GPT-4o/5/o-series, `cl100k_base` cho phần còn lại). Đếm chính
|
|
210
|
-
xác cho OpenAI, xấp xỉ ±5-15% cho Claude/Gemini/khác (các provider này không
|
|
211
|
-
publish tokenizer public).
|
|
243
|
+
**"Model OpenAI từ chối tool"** — GPT thường refuse access filesystem qua text
|
|
244
|
+
protocol. Dùng `/model claude` hoặc `/model deepseek` cho task cần tool.
|
|
212
245
|
|
|
213
246
|
## Notes & limits
|
|
214
247
|
|
|
215
|
-
-
|
|
216
|
-
- Context
|
|
217
|
-
`/clear`
|
|
218
|
-
-
|
|
219
|
-
|
|
248
|
+
- Proxy chia sẻ miễn phí: rate limit và đôi lúc trục trặc là bình thường.
|
|
249
|
+
- Context gửi đầy đủ mỗi lượt, nên phiên rất dài tốn nhiều token — dùng `/compact`
|
|
250
|
+
hoặc `/clear` để gọn lại.
|
|
251
|
+
- Đây là công cụ không chính thức, không liên kết với Anthropic hay các nhà cung
|
|
252
|
+
cấp model.
|
|
220
253
|
|
|
221
254
|
## License
|
|
222
255
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@noobdemon/noob-cli",
|
|
3
|
-
"version": "1.13.
|
|
3
|
+
"version": "1.13.2",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -25,7 +25,8 @@
|
|
|
25
25
|
"format:check": "prettier --check .",
|
|
26
26
|
"test": "vitest run",
|
|
27
27
|
"test:watch": "vitest",
|
|
28
|
-
"check": "
|
|
28
|
+
"i18n:check": "node scripts/check-i18n-orphans.mjs",
|
|
29
|
+
"check": "npm run lint && npm run format:check && npm run i18n:check && npm run test",
|
|
29
30
|
"postpublish": "node scripts/notify-discord.js",
|
|
30
31
|
"prepare": "husky"
|
|
31
32
|
},
|
package/src/agent.js
CHANGED
|
@@ -581,6 +581,7 @@ export async function runAgent({
|
|
|
581
581
|
history,
|
|
582
582
|
model,
|
|
583
583
|
image,
|
|
584
|
+
images,
|
|
584
585
|
signal,
|
|
585
586
|
onTool,
|
|
586
587
|
onStatus,
|
|
@@ -638,6 +639,7 @@ export async function runAgent({
|
|
|
638
639
|
model,
|
|
639
640
|
message,
|
|
640
641
|
image,
|
|
642
|
+
images,
|
|
641
643
|
system,
|
|
642
644
|
signal,
|
|
643
645
|
tokenMeter,
|
|
@@ -818,6 +820,7 @@ async function streamWithRetry({
|
|
|
818
820
|
model,
|
|
819
821
|
message,
|
|
820
822
|
image,
|
|
823
|
+
images,
|
|
821
824
|
system,
|
|
822
825
|
signal,
|
|
823
826
|
tokenMeter,
|
|
@@ -834,6 +837,7 @@ async function streamWithRetry({
|
|
|
834
837
|
model,
|
|
835
838
|
message,
|
|
836
839
|
image,
|
|
840
|
+
images,
|
|
837
841
|
system,
|
|
838
842
|
signal,
|
|
839
843
|
effort,
|
package/src/api.js
CHANGED
|
@@ -158,6 +158,7 @@ export async function stream({
|
|
|
158
158
|
mode = 'chat',
|
|
159
159
|
message,
|
|
160
160
|
image,
|
|
161
|
+
images,
|
|
161
162
|
model,
|
|
162
163
|
system,
|
|
163
164
|
conversation,
|
|
@@ -188,6 +189,7 @@ export async function stream({
|
|
|
188
189
|
mode,
|
|
189
190
|
message: prompt,
|
|
190
191
|
image,
|
|
192
|
+
images,
|
|
191
193
|
model,
|
|
192
194
|
system,
|
|
193
195
|
conversation,
|
|
@@ -265,6 +267,7 @@ async function streamOnce({
|
|
|
265
267
|
mode,
|
|
266
268
|
message,
|
|
267
269
|
image,
|
|
270
|
+
images,
|
|
268
271
|
model,
|
|
269
272
|
system,
|
|
270
273
|
conversation,
|
|
@@ -281,7 +284,14 @@ async function streamOnce({
|
|
|
281
284
|
else if (mode === 'merge') body = { message };
|
|
282
285
|
else {
|
|
283
286
|
body = { message, model, remember: true, memoryToken: getMemoryToken() };
|
|
284
|
-
|
|
287
|
+
// Ảnh: chấp nhận cả `images` (mảng, multi-image) lẫn `image` (1 ảnh, cũ).
|
|
288
|
+
// Gửi `images` (mảng data URL) cho worker; giữ `image` = ảnh đầu cho
|
|
289
|
+
// tương thích ngược với worker/upstream cũ.
|
|
290
|
+
const imgList = Array.isArray(images) ? images.filter(Boolean) : image ? [image] : [];
|
|
291
|
+
if (imgList.length) {
|
|
292
|
+
body.images = imgList;
|
|
293
|
+
body.image = imgList[0];
|
|
294
|
+
}
|
|
285
295
|
if (system) body.customInstructions = system;
|
|
286
296
|
if (Array.isArray(conversation) && conversation.length) body.conversation = conversation;
|
|
287
297
|
if (effort) body.effort = effort;
|
package/src/diff.js
CHANGED
|
@@ -8,6 +8,42 @@
|
|
|
8
8
|
// File lớn hơn (write_file overwrite 1k+ dòng) → caller truncate trước khi gọi.
|
|
9
9
|
import chalk from 'chalk';
|
|
10
10
|
|
|
11
|
+
// Bề rộng khả dụng cho NỘI DUNG diff (trừ tiền tố ' │ ' = 4 cột + '+ '/'- ' = 2).
|
|
12
|
+
// Cap tối thiểu 20 để không wrap loạn ở terminal hẹp; mặc định 80 khi không phải TTY.
|
|
13
|
+
function contentWidth() {
|
|
14
|
+
const cols = process.stdout.columns || 80;
|
|
15
|
+
return Math.max(20, cols - 6);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Soft-wrap 1 đoạn text THUẦN (không màu) thành mảng dòng có vis-width ≤ width.
|
|
19
|
+
// Ưu tiên word boundary; không có space hợp lý → hard-slice. Diff text đã bị
|
|
20
|
+
// slice(0,200) ở caller nên không cần ANSI-aware ở đây.
|
|
21
|
+
function wrapPlain(text, width) {
|
|
22
|
+
if (text.length <= width) return [text];
|
|
23
|
+
const lines = [];
|
|
24
|
+
let remaining = text;
|
|
25
|
+
while (remaining.length > width) {
|
|
26
|
+
let cut = width;
|
|
27
|
+
const lastSpace = remaining.slice(0, width).lastIndexOf(' ');
|
|
28
|
+
if (lastSpace > width * 0.3) cut = lastSpace;
|
|
29
|
+
lines.push(remaining.slice(0, cut).trimEnd());
|
|
30
|
+
remaining = remaining.slice(cut).trimStart();
|
|
31
|
+
}
|
|
32
|
+
if (remaining) lines.push(remaining);
|
|
33
|
+
return lines.length ? lines : [''];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Emit 1 dòng diff đã wrap: dòng đầu có dấu (+/-/space), dòng wrap kế thụt 2 cột
|
|
37
|
+
// cho thẳng cột nội dung. Mỗi dòng con tự bọc tiền tố '│' + tô màu riêng (chalk
|
|
38
|
+
// reset cuối từng đoạn) → wrap KHÔNG mất màu / mất khung.
|
|
39
|
+
function emitDiffLine(out, text, color, sign) {
|
|
40
|
+
const segs = wrapPlain(text, contentWidth());
|
|
41
|
+
for (let i = 0; i < segs.length; i++) {
|
|
42
|
+
const prefix = i === 0 ? sign : ' ';
|
|
43
|
+
out.push(chalk.dim(' │ ') + color(prefix + segs[i]));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
11
47
|
// LCS table giữa 2 mảng lines. Trả về matrix (M+1) x (N+1).
|
|
12
48
|
function lcsTable(a, b) {
|
|
13
49
|
const m = a.length;
|
|
@@ -129,11 +165,9 @@ export function renderUnifiedDiff(
|
|
|
129
165
|
continue;
|
|
130
166
|
}
|
|
131
167
|
const text = String(op.text).slice(0, 200);
|
|
132
|
-
|
|
133
|
-
if (op.type === '
|
|
134
|
-
else
|
|
135
|
-
else line = chalk.dim(' ') + chalk.dim(text);
|
|
136
|
-
out.push(chalk.dim(' │ ') + line);
|
|
168
|
+
if (op.type === 'add') emitDiffLine(out, text, chalk.green, '+ ');
|
|
169
|
+
else if (op.type === 'del') emitDiffLine(out, text, chalk.red, '- ');
|
|
170
|
+
else emitDiffLine(out, text, chalk.dim, ' ');
|
|
137
171
|
printed++;
|
|
138
172
|
}
|
|
139
173
|
}
|
|
@@ -153,7 +187,7 @@ export function renderNewFilePreview(content, { label = '', maxLines = 20 } = {}
|
|
|
153
187
|
const out = [];
|
|
154
188
|
out.push(chalk.dim(' ┌─ ' + label + ' ') + chalk.green(`(file MỚI · ${lines.length} dòng)`));
|
|
155
189
|
for (const l of head) {
|
|
156
|
-
out.
|
|
190
|
+
emitDiffLine(out, l.slice(0, 200), chalk.green, '+ ');
|
|
157
191
|
}
|
|
158
192
|
if (more > 0) out.push(chalk.dim(' │ ') + chalk.dim(`… +${more} dòng nữa`));
|
|
159
193
|
out.push(chalk.dim(' └─'));
|
package/src/i18n.js
CHANGED
|
@@ -26,7 +26,6 @@ export const t = {
|
|
|
26
26
|
// auth
|
|
27
27
|
notLoggedIn:
|
|
28
28
|
'Bạn chưa đăng nhập. Chạy: noob login <api-key>\nChưa có key? Liên hệ admin để lấy key (Pro / Pro+ / Trial).',
|
|
29
|
-
loginOk: (plan) => `Đăng nhập thành công. Gói: ${plan}.`,
|
|
30
29
|
loginSaved: (p) => `Đã lưu API key vào ${p}`,
|
|
31
30
|
loggedOut: 'Đã đăng xuất, xoá API key khỏi máy.',
|
|
32
31
|
needKeyArg: 'Thiếu key. Dùng: noob login <api-key>',
|
|
@@ -173,11 +172,6 @@ export const t = {
|
|
|
173
172
|
loopAutoStop: (n) => `Loop tự dừng sau tick #${n} — model phát <<LOOP_DONE>> (task hoàn tất).`,
|
|
174
173
|
loopAlreadyRunning: 'Đã có loop đang chạy. /loop stop trước khi đặt loop mới.',
|
|
175
174
|
learning: 'đang chưng cất bài học vào noob.md…',
|
|
176
|
-
learnSuggest: (n) =>
|
|
177
|
-
`💡 Phiên này có ${n} lượt. Gõ /learn trước để chưng cất bài học vào noob.md (sau khi /new thì history sẽ mất).`,
|
|
178
|
-
memoryStatus: (lines, rules, notes, ago) =>
|
|
179
|
-
`📝 noob.md: ${lines} dòng (${rules} rules, ${notes} notes) · cập nhật ${ago}`,
|
|
180
|
-
memoryMissing: '📝 noob.md: chưa có — gõ /init để tạo từ dự án.',
|
|
181
175
|
compactRunning: 'đang tóm tắt phiên để gọn ngữ cảnh…',
|
|
182
176
|
compactEmpty: 'Phiên còn trống — không có gì để tóm tắt.',
|
|
183
177
|
compactSkipped: 'Phiên còn ngắn hoặc tóm tắt thất bại — bỏ qua.',
|
|
@@ -199,12 +193,8 @@ export const t = {
|
|
|
199
193
|
'cần mô tả yêu cầu. Ví dụ: /frontend-design landing page cho app nghe nhạc lo-fi',
|
|
200
194
|
workflowRunning: 'đang chạy dynamic workflow đa sub-agent…',
|
|
201
195
|
workflowNoSkill: 'không tìm thấy skills/dynamic-workflows/SKILL.md — skill chưa được cài.',
|
|
202
|
-
workflowNeedArg: 'cần mô tả task. Ví dụ: /workflow audit toàn bộ src/ tìm lỗ hổng SQL injection',
|
|
203
|
-
workflowAgentAutoOn: 'agent mode tự bật cho /workflow (cần spawn_agent)',
|
|
204
196
|
workflowAgentAskHint:
|
|
205
197
|
'🎼 /workflow cần spawn sub-agent (spawn_agent) — agent mode hiện đang TẮT.',
|
|
206
|
-
workflowAgentAskPrompt:
|
|
207
|
-
' bật agent mode và chạy workflow? [y] có, bật & chạy / [n] huỷ (gõ /agent rồi chạy lại nếu muốn) › ',
|
|
208
198
|
workflowAgentEnabled: 'đã bật agent mode cho workflow này.',
|
|
209
199
|
bgWorkflowDone: (label) => `workflow nền ${label} đã xong`,
|
|
210
200
|
bgWorkflowFailed: (label, err) => `workflow nền ${label} lỗi: ${err}`,
|
|
@@ -212,8 +202,6 @@ export const t = {
|
|
|
212
202
|
workflowAgentDenied:
|
|
213
203
|
'đã huỷ /workflow — agent mode vẫn TẮT. Gõ /agent rồi chạy lại lệnh nếu muốn.',
|
|
214
204
|
// saved workflows (CRUD)
|
|
215
|
-
workflowListEmpty: (dir) =>
|
|
216
|
-
`Chưa có workflow đã lưu. Tạo bằng /workflow save <name> <yêu cầu>. Thư mục: ${dir}`,
|
|
217
205
|
workflowListHeader: (dir) => `Workflow đã lưu (${dir}):`,
|
|
218
206
|
workflowSaveNeedArgs: 'Cách dùng: /workflow save <name> <yêu cầu workflow>',
|
|
219
207
|
workflowSaveEmptyPrompt:
|
|
@@ -228,7 +216,6 @@ export const t = {
|
|
|
228
216
|
workflowSaveDescOk: (n, d) => `Đã thêm mô tả cho '${n}': ${d}`,
|
|
229
217
|
workflowRunNeedName: 'Cách dùng: /workflow run <name> [thêm ngữ cảnh]',
|
|
230
218
|
workflowRunError: (n, e) => `Không nạp được workflow '${n}': ${e}`,
|
|
231
|
-
workflowRunOk: (n) => `Chạy workflow đã lưu '${n}'…`,
|
|
232
219
|
workflowRunPreviewBuiltin: (n, title) => `Built-in workflow '${n}' (${title})`,
|
|
233
220
|
workflowRunPreviewSaved: (n) => `Workflow đã lưu '${n}'`,
|
|
234
221
|
workflowLoadNeedName: 'Cách dùng: /workflow load <name>',
|
|
@@ -267,7 +254,6 @@ export const t = {
|
|
|
267
254
|
updateBgDone: 'Đang cập nhật nền. Mở lại noob để dùng bản mới.',
|
|
268
255
|
updateChecking: 'Đang kiểm tra cập nhật…',
|
|
269
256
|
updateLatest: (cur) => `Đã ở bản mới nhất (${cur}).`,
|
|
270
|
-
updating: 'Đang cập nhật…',
|
|
271
257
|
updateOk: '✓ Cập nhật xong. Mở lại noob để dùng bản mới.',
|
|
272
258
|
updateFail: '✗ Cập nhật thất bại. Thử thủ công: npm i -g @noobdemon/noob-cli@latest',
|
|
273
259
|
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Slash command /goal — HARD GOAL của phiên (completion requirement).
|
|
2
|
+
// Tách khỏi repl.js để giảm phình god module. Dependency (state, tui, persist,
|
|
3
|
+
// helper màu c) inject qua tham số — không truy cập closure startRepl.
|
|
4
|
+
//
|
|
5
|
+
// Set xong sẽ inject vào MỌI prompt tới khi /goal clear. Cú pháp:
|
|
6
|
+
// /goal → xem goal hiện tại
|
|
7
|
+
// /goal <mục tiêu> → đặt goal
|
|
8
|
+
// /goal clear|off|xoá|xoa → xoá goal
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {object} args
|
|
12
|
+
* @param {string} args.arg - text sau "/goal "
|
|
13
|
+
* @param {object} args.state - session state (đọc/ghi state.goal)
|
|
14
|
+
* @param {object} args.tui - TUI (tui.setGoal)
|
|
15
|
+
* @param {Function} args.persist - lưu phiên xuống đĩa
|
|
16
|
+
* @param {object} args.c - helper màu (c.accent/c.dim)
|
|
17
|
+
*/
|
|
18
|
+
export function runGoalCommand({ arg, state, tui, persist, c }) {
|
|
19
|
+
const v = String(arg || '').trim();
|
|
20
|
+
if (!v) {
|
|
21
|
+
if (state.goal) console.log(c.accent(' 🎯 goal: ') + state.goal);
|
|
22
|
+
else console.log(c.dim(' chưa đặt goal. Cú pháp: /goal <mục tiêu> · /goal clear để xoá'));
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const lower = v.toLowerCase();
|
|
26
|
+
if (lower === 'clear' || lower === 'off' || lower === 'xoá' || lower === 'xoa') {
|
|
27
|
+
state.goal = null;
|
|
28
|
+
tui.setGoal('');
|
|
29
|
+
console.log(c.dim(' đã xoá goal'));
|
|
30
|
+
persist();
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
state.goal = v;
|
|
34
|
+
tui.setGoal(v);
|
|
35
|
+
console.log(c.accent(' 🎯 đã đặt goal: ') + v);
|
|
36
|
+
persist();
|
|
37
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// Slash command /kg — knowledge graph CRUD (port từ mcp-knowledge-graph).
|
|
2
|
+
// Tách khỏi repl.js để giảm độ phình của god module. Mọi dependency (hàm kg*,
|
|
3
|
+
// helper màu c) được inject qua tham số — không truy cập closure startRepl,
|
|
4
|
+
// nên test được độc lập.
|
|
5
|
+
//
|
|
6
|
+
// Sub-cmd: list/ls, path, add, obs, link, unlink, unobs, get, search, rm.
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {object} args
|
|
10
|
+
* @param {string} args.arg - phần text sau "/kg " (vd "add Foo type obs")
|
|
11
|
+
* @param {object} args.c - bảng helper màu (c.ok/c.err/c.dim)
|
|
12
|
+
* @param {object} args.kg - các hàm knowledge graph + error class
|
|
13
|
+
*/
|
|
14
|
+
export async function runKgCommand({ arg, c, kg }) {
|
|
15
|
+
const {
|
|
16
|
+
kgLoad,
|
|
17
|
+
kgCreateEntities,
|
|
18
|
+
kgCreateRelations,
|
|
19
|
+
kgAddObservations,
|
|
20
|
+
kgDeleteEntities,
|
|
21
|
+
kgDeleteObservations,
|
|
22
|
+
kgDeleteRelations,
|
|
23
|
+
kgSearchNodes,
|
|
24
|
+
kgOpenNodes,
|
|
25
|
+
kgFormat,
|
|
26
|
+
kgFilePath,
|
|
27
|
+
KGEntityNotFound,
|
|
28
|
+
KGMarkerError,
|
|
29
|
+
} = kg;
|
|
30
|
+
|
|
31
|
+
const argText = String(arg || '').trim();
|
|
32
|
+
const m = argText.match(/^(\S+)(?:\s+([\s\S]*))?$/);
|
|
33
|
+
const sub = m ? m[1].toLowerCase() : '';
|
|
34
|
+
const rest = m && m[2] ? m[2].trim() : '';
|
|
35
|
+
try {
|
|
36
|
+
if (!sub || sub === 'list' || sub === 'ls') {
|
|
37
|
+
const g = await kgLoad();
|
|
38
|
+
if (!g.entities.length && !g.relations.length) {
|
|
39
|
+
console.log(c.dim(` KG rỗng (${kgFilePath()})`));
|
|
40
|
+
} else {
|
|
41
|
+
console.log(kgFormat(g));
|
|
42
|
+
}
|
|
43
|
+
} else if (sub === 'path') {
|
|
44
|
+
console.log(c.dim(' ') + kgFilePath());
|
|
45
|
+
} else if (sub === 'add') {
|
|
46
|
+
const m2 = rest.match(/^(\S+)\s+(\S+)(?:\s+([\s\S]+))?$/);
|
|
47
|
+
if (!m2) {
|
|
48
|
+
console.log(c.err(' Cú pháp: /kg add <Name> <type> [<obs1>; <obs2>; ...]'));
|
|
49
|
+
} else {
|
|
50
|
+
const [, name, etype, obsRaw] = m2;
|
|
51
|
+
const observations = obsRaw
|
|
52
|
+
? obsRaw
|
|
53
|
+
.split(';')
|
|
54
|
+
.map((s) => s.trim())
|
|
55
|
+
.filter(Boolean)
|
|
56
|
+
: [];
|
|
57
|
+
const created = await kgCreateEntities([{ name, entityType: etype, observations }]);
|
|
58
|
+
if (!created.length)
|
|
59
|
+
console.log(c.dim(` Entity '${name}' đã tồn tại — không tạo lại`));
|
|
60
|
+
else
|
|
61
|
+
console.log(c.ok(' ✓ ') + `đã thêm ${name} [${etype}] (${observations.length} obs)`);
|
|
62
|
+
}
|
|
63
|
+
} else if (sub === 'obs') {
|
|
64
|
+
const m2 = rest.match(/^(\S+)\s+([\s\S]+)$/);
|
|
65
|
+
if (!m2) {
|
|
66
|
+
console.log(c.err(' Cú pháp: /kg obs <Name> <observation text>'));
|
|
67
|
+
} else {
|
|
68
|
+
const [, name, obs] = m2;
|
|
69
|
+
const out = await kgAddObservations([{ entityName: name, contents: [obs] }]);
|
|
70
|
+
const added = out[0]?.addedObservations || [];
|
|
71
|
+
if (!added.length)
|
|
72
|
+
console.log(c.dim(` Observation đã có cho '${name}' — không thêm lại`));
|
|
73
|
+
else console.log(c.ok(' ✓ ') + `đã thêm obs cho ${name}: ${added[0]}`);
|
|
74
|
+
}
|
|
75
|
+
} else if (sub === 'link') {
|
|
76
|
+
const parts = rest.split(/\s+/).filter(Boolean);
|
|
77
|
+
if (parts.length < 3) {
|
|
78
|
+
console.log(c.err(' Cú pháp: /kg link <From> <verb> <To>'));
|
|
79
|
+
} else {
|
|
80
|
+
const [from, verb, ...toParts] = parts;
|
|
81
|
+
const to = toParts.join(' ');
|
|
82
|
+
const created = await kgCreateRelations([{ from, to, relationType: verb }]);
|
|
83
|
+
if (!created.length) console.log(c.dim(' Relation đã tồn tại — không tạo lại'));
|
|
84
|
+
else console.log(c.ok(' ✓ ') + `${from} --${verb}--> ${to}`);
|
|
85
|
+
}
|
|
86
|
+
} else if (sub === 'unlink') {
|
|
87
|
+
const parts = rest.split(/\s+/).filter(Boolean);
|
|
88
|
+
if (parts.length < 3) {
|
|
89
|
+
console.log(c.err(' Cú pháp: /kg unlink <From> <verb> <To>'));
|
|
90
|
+
} else {
|
|
91
|
+
const [from, verb, ...toParts] = parts;
|
|
92
|
+
const to = toParts.join(' ');
|
|
93
|
+
const out = await kgDeleteRelations([{ from, to, relationType: verb }]);
|
|
94
|
+
if (!out.deleted) console.log(c.dim(' Không có relation nào khớp'));
|
|
95
|
+
else console.log(c.ok(' ✓ ') + `xóa relation ${from} --${verb}--> ${to}`);
|
|
96
|
+
}
|
|
97
|
+
} else if (sub === 'unobs') {
|
|
98
|
+
const m2 = rest.match(/^(\S+)\s+([\s\S]+)$/);
|
|
99
|
+
if (!m2) {
|
|
100
|
+
console.log(c.err(' Cú pháp: /kg unobs <Name> <observation text>'));
|
|
101
|
+
} else {
|
|
102
|
+
const [, name, obs] = m2;
|
|
103
|
+
await kgDeleteObservations([{ entityName: name, observations: [obs] }]);
|
|
104
|
+
console.log(c.ok(' ✓ ') + `xóa obs '${obs}' khỏi ${name} (silent nếu không có)`);
|
|
105
|
+
}
|
|
106
|
+
} else if (sub === 'get') {
|
|
107
|
+
if (!rest) {
|
|
108
|
+
console.log(c.err(' Cú pháp: /kg get <Name1> [<Name2> ...]'));
|
|
109
|
+
} else {
|
|
110
|
+
const names = rest.split(/\s+/).filter(Boolean);
|
|
111
|
+
const g = await kgOpenNodes(names);
|
|
112
|
+
if (!g.entities.length)
|
|
113
|
+
console.log(c.dim(` Không tìm thấy entity: ${names.join(', ')}`));
|
|
114
|
+
else console.log(kgFormat(g));
|
|
115
|
+
}
|
|
116
|
+
} else if (sub === 'search') {
|
|
117
|
+
if (!rest) {
|
|
118
|
+
console.log(c.err(' Cú pháp: /kg search <query>'));
|
|
119
|
+
} else {
|
|
120
|
+
const g = await kgSearchNodes(rest);
|
|
121
|
+
if (!g.entities.length) console.log(c.dim(` Không có kết quả cho "${rest}"`));
|
|
122
|
+
else console.log(kgFormat(g));
|
|
123
|
+
}
|
|
124
|
+
} else if (sub === 'rm' || sub === 'remove' || sub === 'delete') {
|
|
125
|
+
if (!rest) {
|
|
126
|
+
console.log(c.err(' Cú pháp: /kg rm <Name1> [<Name2> ...]'));
|
|
127
|
+
} else {
|
|
128
|
+
const names = rest.split(/\s+/).filter(Boolean);
|
|
129
|
+
const out = await kgDeleteEntities(names);
|
|
130
|
+
console.log(c.ok(' ✓ ') + `đã xóa ${out.deleted} entity (cascade relation)`);
|
|
131
|
+
}
|
|
132
|
+
} else {
|
|
133
|
+
console.log(
|
|
134
|
+
c.err(' Sub-command không nhận diện. ') +
|
|
135
|
+
c.dim('Dùng: list | path | add | obs | link | unlink | unobs | get | search | rm')
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
} catch (e) {
|
|
139
|
+
if (e instanceof KGEntityNotFound) {
|
|
140
|
+
console.log(c.err(' ✗ ') + e.message + c.dim(' (gõ /kg add trước khi /kg obs)'));
|
|
141
|
+
} else if (e instanceof KGMarkerError) {
|
|
142
|
+
console.log(c.err(' ✗ ') + e.message);
|
|
143
|
+
} else {
|
|
144
|
+
console.log(c.err(' ✗ ') + (e?.message || String(e)));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
package/src/repl.js
CHANGED
|
@@ -72,6 +72,8 @@ import {
|
|
|
72
72
|
import { getBuiltinWorkflow, loadBuiltinPrompt } from './workflows-builtin.js';
|
|
73
73
|
import { SLASH, completeInput, mentionedFiles } from './repl/complete.js';
|
|
74
74
|
import { parseTodosFromHistory } from './repl/todos.js';
|
|
75
|
+
import { runKgCommand } from './repl/commands/kg.js';
|
|
76
|
+
import { runGoalCommand } from './repl/commands/goal.js';
|
|
75
77
|
import {
|
|
76
78
|
ULTRA_DONE,
|
|
77
79
|
MAX_QUESTS,
|
|
@@ -1566,12 +1568,18 @@ NGUYÊN TẮC:
|
|
|
1566
1568
|
}
|
|
1567
1569
|
|
|
1568
1570
|
const files = mentionedFiles(text);
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1571
|
+
// Multi-image: gom mọi ảnh paste (opts.images) + ảnh @file được nhắc tới.
|
|
1572
|
+
// Giữ thứ tự: ảnh paste trước, rồi ảnh @file. Dedupe theo data URL.
|
|
1573
|
+
const pasted = Array.isArray(opts.images) ? opts.images.filter(Boolean) : [];
|
|
1574
|
+
const mentionedImages = files
|
|
1575
|
+
.filter((f) => IMAGE_MIME[path.extname(f).toLowerCase()])
|
|
1576
|
+
.map((f) => imageDataUrl(f))
|
|
1577
|
+
.filter(Boolean);
|
|
1578
|
+
const images = [...new Set([...pasted, ...mentionedImages])];
|
|
1579
|
+
const image = images[0] || null;
|
|
1572
1580
|
const content = files.length
|
|
1573
1581
|
? text +
|
|
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.${
|
|
1582
|
+
`\n\n[File người dùng nhắc tới bằng @: ${files.join(', ')} — đọc bằng read_file nếu cần.${images.length ? ` ${images.length} ảnh đã được đính kèm cho model vision.` : ''}]`
|
|
1575
1583
|
: text;
|
|
1576
1584
|
state.history.push({ role: 'user', content });
|
|
1577
1585
|
// Update terminal title với session name (trích từ message đầu).
|
|
@@ -1616,6 +1624,7 @@ NGUYÊN TẮC:
|
|
|
1616
1624
|
history: state.history,
|
|
1617
1625
|
model: state.model.id,
|
|
1618
1626
|
image,
|
|
1627
|
+
images,
|
|
1619
1628
|
signal: abort.signal,
|
|
1620
1629
|
tokenMeter,
|
|
1621
1630
|
goal: state.goal,
|
|
@@ -1926,152 +1935,32 @@ NGUYÊN TẮC:
|
|
|
1926
1935
|
break;
|
|
1927
1936
|
}
|
|
1928
1937
|
case 'kg': {
|
|
1929
|
-
// Knowledge graph CRUD —
|
|
1930
|
-
//
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
const [, name, etype, obsRaw] = m2;
|
|
1951
|
-
const observations = obsRaw
|
|
1952
|
-
? obsRaw
|
|
1953
|
-
.split(';')
|
|
1954
|
-
.map((s) => s.trim())
|
|
1955
|
-
.filter(Boolean)
|
|
1956
|
-
: [];
|
|
1957
|
-
const created = await kgCreateEntities([{ name, entityType: etype, observations }]);
|
|
1958
|
-
if (!created.length)
|
|
1959
|
-
console.log(c.dim(` Entity '${name}' đã tồn tại — không tạo lại`));
|
|
1960
|
-
else
|
|
1961
|
-
console.log(
|
|
1962
|
-
c.ok(' ✓ ') + `đã thêm ${name} [${etype}] (${observations.length} obs)`
|
|
1963
|
-
);
|
|
1964
|
-
}
|
|
1965
|
-
} else if (sub === 'obs') {
|
|
1966
|
-
const m2 = rest.match(/^(\S+)\s+([\s\S]+)$/);
|
|
1967
|
-
if (!m2) {
|
|
1968
|
-
console.log(c.err(' Cú pháp: /kg obs <Name> <observation text>'));
|
|
1969
|
-
} else {
|
|
1970
|
-
const [, name, obs] = m2;
|
|
1971
|
-
const out = await kgAddObservations([{ entityName: name, contents: [obs] }]);
|
|
1972
|
-
const added = out[0]?.addedObservations || [];
|
|
1973
|
-
if (!added.length)
|
|
1974
|
-
console.log(c.dim(` Observation đã có cho '${name}' — không thêm lại`));
|
|
1975
|
-
else console.log(c.ok(' ✓ ') + `đã thêm obs cho ${name}: ${added[0]}`);
|
|
1976
|
-
}
|
|
1977
|
-
} else if (sub === 'link') {
|
|
1978
|
-
const parts = rest.split(/\s+/).filter(Boolean);
|
|
1979
|
-
if (parts.length < 3) {
|
|
1980
|
-
console.log(c.err(' Cú pháp: /kg link <From> <verb> <To>'));
|
|
1981
|
-
} else {
|
|
1982
|
-
const [from, verb, ...toParts] = parts;
|
|
1983
|
-
const to = toParts.join(' ');
|
|
1984
|
-
const created = await kgCreateRelations([{ from, to, relationType: verb }]);
|
|
1985
|
-
if (!created.length) console.log(c.dim(' Relation đã tồn tại — không tạo lại'));
|
|
1986
|
-
else console.log(c.ok(' ✓ ') + `${from} --${verb}--> ${to}`);
|
|
1987
|
-
}
|
|
1988
|
-
} else if (sub === 'unlink') {
|
|
1989
|
-
const parts = rest.split(/\s+/).filter(Boolean);
|
|
1990
|
-
if (parts.length < 3) {
|
|
1991
|
-
console.log(c.err(' Cú pháp: /kg unlink <From> <verb> <To>'));
|
|
1992
|
-
} else {
|
|
1993
|
-
const [from, verb, ...toParts] = parts;
|
|
1994
|
-
const to = toParts.join(' ');
|
|
1995
|
-
const out = await kgDeleteRelations([{ from, to, relationType: verb }]);
|
|
1996
|
-
if (!out.deleted) console.log(c.dim(' Không có relation nào khớp'));
|
|
1997
|
-
else console.log(c.ok(' ✓ ') + `xóa relation ${from} --${verb}--> ${to}`);
|
|
1998
|
-
}
|
|
1999
|
-
} else if (sub === 'unobs') {
|
|
2000
|
-
const m2 = rest.match(/^(\S+)\s+([\s\S]+)$/);
|
|
2001
|
-
if (!m2) {
|
|
2002
|
-
console.log(c.err(' Cú pháp: /kg unobs <Name> <observation text>'));
|
|
2003
|
-
} else {
|
|
2004
|
-
const [, name, obs] = m2;
|
|
2005
|
-
await kgDeleteObservations([{ entityName: name, observations: [obs] }]);
|
|
2006
|
-
console.log(c.ok(' ✓ ') + `xóa obs '${obs}' khỏi ${name} (silent nếu không có)`);
|
|
2007
|
-
}
|
|
2008
|
-
} else if (sub === 'get') {
|
|
2009
|
-
if (!rest) {
|
|
2010
|
-
console.log(c.err(' Cú pháp: /kg get <Name1> [<Name2> ...]'));
|
|
2011
|
-
} else {
|
|
2012
|
-
const names = rest.split(/\s+/).filter(Boolean);
|
|
2013
|
-
const g = await kgOpenNodes(names);
|
|
2014
|
-
if (!g.entities.length)
|
|
2015
|
-
console.log(c.dim(` Không tìm thấy entity: ${names.join(', ')}`));
|
|
2016
|
-
else console.log(kgFormat(g));
|
|
2017
|
-
}
|
|
2018
|
-
} else if (sub === 'search') {
|
|
2019
|
-
if (!rest) {
|
|
2020
|
-
console.log(c.err(' Cú pháp: /kg search <query>'));
|
|
2021
|
-
} else {
|
|
2022
|
-
const g = await kgSearchNodes(rest);
|
|
2023
|
-
if (!g.entities.length) console.log(c.dim(` Không có kết quả cho "${rest}"`));
|
|
2024
|
-
else console.log(kgFormat(g));
|
|
2025
|
-
}
|
|
2026
|
-
} else if (sub === 'rm' || sub === 'remove' || sub === 'delete') {
|
|
2027
|
-
if (!rest) {
|
|
2028
|
-
console.log(c.err(' Cú pháp: /kg rm <Name1> [<Name2> ...]'));
|
|
2029
|
-
} else {
|
|
2030
|
-
const names = rest.split(/\s+/).filter(Boolean);
|
|
2031
|
-
const out = await kgDeleteEntities(names);
|
|
2032
|
-
console.log(c.ok(' ✓ ') + `đã xóa ${out.deleted} entity (cascade relation)`);
|
|
2033
|
-
}
|
|
2034
|
-
} else {
|
|
2035
|
-
console.log(
|
|
2036
|
-
c.err(' Sub-command không nhận diện. ') +
|
|
2037
|
-
c.dim('Dùng: list | path | add | obs | link | unlink | unobs | get | search | rm')
|
|
2038
|
-
);
|
|
2039
|
-
}
|
|
2040
|
-
} catch (e) {
|
|
2041
|
-
if (e instanceof KGEntityNotFound) {
|
|
2042
|
-
console.log(c.err(' ✗ ') + e.message + c.dim(' (gõ /kg add trước khi /kg obs)'));
|
|
2043
|
-
} else if (e instanceof KGMarkerError) {
|
|
2044
|
-
console.log(c.err(' ✗ ') + e.message);
|
|
2045
|
-
} else {
|
|
2046
|
-
console.log(c.err(' ✗ ') + (e?.message || String(e)));
|
|
2047
|
-
}
|
|
2048
|
-
}
|
|
1938
|
+
// Knowledge graph CRUD — logic tách sang src/repl/commands/kg.js.
|
|
1939
|
+
// Inject các hàm kg* đã import + helper màu c (không truy cập closure).
|
|
1940
|
+
await runKgCommand({
|
|
1941
|
+
arg,
|
|
1942
|
+
c,
|
|
1943
|
+
kg: {
|
|
1944
|
+
kgLoad,
|
|
1945
|
+
kgCreateEntities,
|
|
1946
|
+
kgCreateRelations,
|
|
1947
|
+
kgAddObservations,
|
|
1948
|
+
kgDeleteEntities,
|
|
1949
|
+
kgDeleteObservations,
|
|
1950
|
+
kgDeleteRelations,
|
|
1951
|
+
kgSearchNodes,
|
|
1952
|
+
kgOpenNodes,
|
|
1953
|
+
kgFormat,
|
|
1954
|
+
kgFilePath,
|
|
1955
|
+
KGEntityNotFound,
|
|
1956
|
+
KGMarkerError,
|
|
1957
|
+
},
|
|
1958
|
+
});
|
|
2049
1959
|
break;
|
|
2050
1960
|
}
|
|
2051
1961
|
case 'goal': {
|
|
2052
|
-
// HARD GOAL
|
|
2053
|
-
|
|
2054
|
-
const v = arg.trim();
|
|
2055
|
-
if (!v) {
|
|
2056
|
-
if (state.goal) console.log(c.accent(' 🎯 goal: ') + state.goal);
|
|
2057
|
-
else
|
|
2058
|
-
console.log(c.dim(' chưa đặt goal. Cú pháp: /goal <mục tiêu> · /goal clear để xoá'));
|
|
2059
|
-
} else if (
|
|
2060
|
-
v.toLowerCase() === 'clear' ||
|
|
2061
|
-
v.toLowerCase() === 'off' ||
|
|
2062
|
-
v.toLowerCase() === 'xoá' ||
|
|
2063
|
-
v.toLowerCase() === 'xoa'
|
|
2064
|
-
) {
|
|
2065
|
-
state.goal = null;
|
|
2066
|
-
tui.setGoal('');
|
|
2067
|
-
console.log(c.dim(' đã xoá goal'));
|
|
2068
|
-
persist();
|
|
2069
|
-
} else {
|
|
2070
|
-
state.goal = v;
|
|
2071
|
-
tui.setGoal(v);
|
|
2072
|
-
console.log(c.accent(' 🎯 đã đặt goal: ') + v);
|
|
2073
|
-
persist();
|
|
2074
|
-
}
|
|
1962
|
+
// HARD GOAL — logic tách sang src/repl/commands/goal.js. Inject deps.
|
|
1963
|
+
runGoalCommand({ arg, state, tui, persist, c });
|
|
2075
1964
|
break;
|
|
2076
1965
|
}
|
|
2077
1966
|
case 'tokens': {
|
package/src/tools.js
CHANGED
|
@@ -348,7 +348,17 @@ export const TOOLS = {
|
|
|
348
348
|
// raw:true hoặc không phải HTML → trả nguyên văn (đã clip). Ngược lại strip HTML.
|
|
349
349
|
const isHtml = /html/i.test(ct) || /^\s*<(!doctype|html)/i.test(body);
|
|
350
350
|
const text = raw || !isHtml ? body : htmlToText(body);
|
|
351
|
-
|
|
351
|
+
const trimmed = text.trim();
|
|
352
|
+
// SPA hint: HTML lớn nhưng strip ra gần rỗng → nhiều khả năng client-render (JS).
|
|
353
|
+
// htmlToText là regex, không chạy JS → trả signal rõ thay vì để model tưởng trang trống.
|
|
354
|
+
if (!raw && isHtml && trimmed.length < 200 && body.length > 2000) {
|
|
355
|
+
return clip(
|
|
356
|
+
head +
|
|
357
|
+
`[web_fetch: trang trả ${body.length} byte HTML nhưng chỉ strip được ${trimmed.length} ký tự text — nhiều khả năng render bằng JS (SPA). Thử lại với raw:true để xem HTML thô, hoặc tìm API/endpoint dữ liệu của trang.]\n\n` +
|
|
358
|
+
trimmed
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
return clip(head + trimmed);
|
|
352
362
|
},
|
|
353
363
|
|
|
354
364
|
async write_file({ path: p, content }, { signal } = {}) {
|
package/src/tui.js
CHANGED
|
@@ -13,6 +13,20 @@ import { c } from './ui.js';
|
|
|
13
13
|
const ESC = '\x1b';
|
|
14
14
|
const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
|
|
15
15
|
const visLen = (s) => s.replace(ANSI_RE, '').length;
|
|
16
|
+
// Trích chuỗi mã ANSI còn "mở" tính tới cuối `text`: replay mọi SGR escape,
|
|
17
|
+
// reset (\x1b[0m / \x1b[m) xoá sạch, mã khác tích luỹ. Trả về chuỗi để TÁI MỞ
|
|
18
|
+
// màu ở đầu dòng wrap kế tiếp — chống mất màu khi soft-wrap cắt giữa vùng màu.
|
|
19
|
+
function openAnsi(text) {
|
|
20
|
+
let codes = [];
|
|
21
|
+
const re = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
|
|
22
|
+
let m;
|
|
23
|
+
while ((m = re.exec(text))) {
|
|
24
|
+
const seq = m[0];
|
|
25
|
+
if (seq === '\x1b[0m' || seq === '\x1b[m') codes = [];
|
|
26
|
+
else codes.push(seq);
|
|
27
|
+
}
|
|
28
|
+
return codes.join('');
|
|
29
|
+
}
|
|
16
30
|
function readClipboardImageDataUrl() {
|
|
17
31
|
if (process.platform !== 'win32') return null;
|
|
18
32
|
try {
|
|
@@ -72,16 +86,20 @@ function softWrapLine(text, width) {
|
|
|
72
86
|
if (visLen(text) <= width) return [close(text)];
|
|
73
87
|
const lines = [];
|
|
74
88
|
let remaining = text;
|
|
89
|
+
let carry = ''; // mã ANSI còn mở từ dòng trước → tái mở ở đầu dòng kế.
|
|
75
90
|
while (remaining) {
|
|
76
|
-
|
|
77
|
-
|
|
91
|
+
const piece = carry + remaining;
|
|
92
|
+
if (visLen(piece) <= width) {
|
|
93
|
+
lines.push(piece);
|
|
78
94
|
break;
|
|
79
95
|
}
|
|
80
|
-
let cutPos = findVisPos(remaining, width);
|
|
96
|
+
let cutPos = findVisPos(remaining, width - visLen(carry));
|
|
81
97
|
const slice = remaining.slice(0, cutPos);
|
|
82
98
|
const lastSpace = slice.lastIndexOf(' ');
|
|
83
99
|
if (lastSpace > width * 0.3) cutPos = lastSpace;
|
|
84
|
-
|
|
100
|
+
const head = remaining.slice(0, cutPos).trimEnd();
|
|
101
|
+
lines.push(carry + head);
|
|
102
|
+
carry = hasAnsi ? openAnsi(carry + head) : '';
|
|
85
103
|
remaining = remaining.slice(cutPos).trimStart();
|
|
86
104
|
}
|
|
87
105
|
return lines.map(close);
|
|
@@ -97,17 +115,21 @@ function wrapText(text, width, maxLines) {
|
|
|
97
115
|
// nằm ở đâu trong tổng stream.
|
|
98
116
|
const lines = [];
|
|
99
117
|
let remaining = text;
|
|
118
|
+
let carry = ''; // mã ANSI còn mở từ dòng trước → tái mở ở đầu dòng kế.
|
|
100
119
|
while (remaining) {
|
|
101
|
-
|
|
102
|
-
|
|
120
|
+
const piece = carry + remaining;
|
|
121
|
+
if (visLen(piece) <= width) {
|
|
122
|
+
lines.push(piece);
|
|
103
123
|
remaining = '';
|
|
104
124
|
break;
|
|
105
125
|
}
|
|
106
|
-
let cutPos = findVisPos(remaining, width);
|
|
126
|
+
let cutPos = findVisPos(remaining, width - visLen(carry));
|
|
107
127
|
const slice = remaining.slice(0, cutPos);
|
|
108
128
|
const lastSpace = slice.lastIndexOf(' ');
|
|
109
129
|
if (lastSpace > width * 0.3) cutPos = lastSpace;
|
|
110
|
-
|
|
130
|
+
const head = remaining.slice(0, cutPos).trimEnd();
|
|
131
|
+
lines.push(carry + head);
|
|
132
|
+
carry = hasAnsi ? openAnsi(carry + head) : '';
|
|
111
133
|
remaining = remaining.slice(cutPos).trimStart();
|
|
112
134
|
}
|
|
113
135
|
if (lines.length > maxLines) {
|
|
@@ -221,6 +243,8 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, onCtrlT, com
|
|
|
221
243
|
let frameTimer = null;
|
|
222
244
|
let prevRows = 0;
|
|
223
245
|
let drawn = false;
|
|
246
|
+
let lastUpBy = 0; // số dòng con trỏ nằm TRÊN dòng cuối đã vẽ (placeCursor set);
|
|
247
|
+
// eraseSeq dùng để biết con trỏ đang ở đâu mà nhảy lên đúng dòng đầu.
|
|
224
248
|
|
|
225
249
|
let promptLabel = '';
|
|
226
250
|
// Thanh nhập = mảng "ô" + con trỏ. Mỗi ô là 1 ký tự {c} hoặc 1 khối dán
|
|
@@ -288,18 +312,30 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, onCtrlT, com
|
|
|
288
312
|
return preview ? `[pasted ${x.lines} lines: "${preview}"]` : `[pasted ${x.lines} lines]`;
|
|
289
313
|
};
|
|
290
314
|
const fullText = () => cells.map(cellStr).join('');
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
.
|
|
315
|
+
// Con trỏ ẢO: ô tại vị trí `cur` được đảo màu (reverse video \x1b[7m). Vẽ con
|
|
316
|
+
// trỏ trong CHÍNH text thay vì dựa con trỏ phần cứng → không phụ thuộc terminal
|
|
317
|
+
// có tôn trọng DECSCUSR / vẽ block-cursor đè sai ô hay không. cur == len → con
|
|
318
|
+
// trỏ sau ký tự cuối: đảo màu 1 space ở cuối.
|
|
319
|
+
const REV = `${ESC}[7m`;
|
|
320
|
+
const UNREV = `${ESC}[27m`;
|
|
321
|
+
const cellColored = (x) => {
|
|
322
|
+
if (x.image) return c.dim(imageLabel(x));
|
|
323
|
+
if (x.paste === undefined) return x.c;
|
|
324
|
+
const preview = pastePreview(x.paste);
|
|
325
|
+
const label = preview
|
|
326
|
+
? `[pasted ${x.lines} lines: "${preview}"]`
|
|
327
|
+
: `[pasted ${x.lines} lines]`;
|
|
328
|
+
return c.dim(label);
|
|
329
|
+
};
|
|
330
|
+
const coloredInput = () => {
|
|
331
|
+
let out = '';
|
|
332
|
+
for (let i = 0; i < cells.length; i++) {
|
|
333
|
+
const rendered = cellColored(cells[i]);
|
|
334
|
+
out += i === cur ? `${REV}${rendered}${UNREV}` : rendered;
|
|
335
|
+
}
|
|
336
|
+
if (cur >= cells.length) out += `${REV} ${UNREV}`; // con trỏ ở cuối
|
|
337
|
+
return out;
|
|
338
|
+
};
|
|
303
339
|
|
|
304
340
|
// Dựng thanh nhập + tính vị trí con trỏ trên màn (`cursorScreenCol` +
|
|
305
341
|
// `cursorScreenRow`). Vừa khung → tô màu đầy đủ, 1 dòng. Tràn khung → soft-
|
|
@@ -308,6 +344,7 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, onCtrlT, com
|
|
|
308
344
|
// thêm "…".
|
|
309
345
|
let cursorScreenCol = 0;
|
|
310
346
|
let cursorScreenRow = 0;
|
|
347
|
+
let trailingRows = 0; // số dòng trang trí SAU bar (thanh chắn dưới); placeCursor nhảy lên qua chúng
|
|
311
348
|
let barRows = 1; // số dòng bar hiện tại; cập nhật bởi renderBar. placeCursor
|
|
312
349
|
// dùng để tính upBy — KHÔNG dùng totalRows (gồm cả top/menu
|
|
313
350
|
// rows phía trên) vì sẽ kéo cursor lên quá cao, lần commit
|
|
@@ -377,7 +414,21 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, onCtrlT, com
|
|
|
377
414
|
cursorScreenCol = promptW + colInLine;
|
|
378
415
|
barRows = lines.length;
|
|
379
416
|
|
|
380
|
-
|
|
417
|
+
// Con trỏ ẢO cho nhánh wrap: chèn ô đảo màu (reverse video) tại `colInLine`
|
|
418
|
+
// của dòng `curLine`. Con trỏ ở cuối ô (colInLine == lineLen) → đảo màu 1
|
|
419
|
+
// space append; giữa dòng → đảo màu ký tự tại đó. KHÔNG dùng con trỏ phần
|
|
420
|
+
// cứng (đã ẩn suốt phiên) nên nhánh này PHẢI tự vẽ con trỏ, nếu không wrap
|
|
421
|
+
// sẽ mất con trỏ.
|
|
422
|
+
return lines
|
|
423
|
+
.map((l, i) => {
|
|
424
|
+
let body = l;
|
|
425
|
+
if (i === curLine) {
|
|
426
|
+
if (colInLine >= l.length) body = l + `${REV} ${UNREV}`;
|
|
427
|
+
else body = l.slice(0, colInLine) + `${REV}${l[colInLine]}${UNREV}` + l.slice(colInLine + 1);
|
|
428
|
+
}
|
|
429
|
+
return (i === 0 ? promptLabel : indent) + body;
|
|
430
|
+
})
|
|
431
|
+
.join('\n');
|
|
381
432
|
}
|
|
382
433
|
function topRow() {
|
|
383
434
|
if (liveOut) {
|
|
@@ -521,13 +572,29 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, onCtrlT, com
|
|
|
521
572
|
if (top !== null) r.push(...top);
|
|
522
573
|
for (const mr of menuRows()) r.push(mr);
|
|
523
574
|
}
|
|
575
|
+
// Khung nhập: kẻ 2 thanh chắn (trên + dưới) quanh thanh nhập để khu gõ luôn
|
|
576
|
+
// rõ ràng, KỂ CẢ khi model đang chạy (rows() chạy cùng path busy/rảnh). Thanh
|
|
577
|
+
// chắn là dòng trang trí tĩnh — KHÔNG nằm trong barRows (renderBar set), nên
|
|
578
|
+
// placeCursor phải nhảy lên qua thanh chắn DƯỚI: cộng nó vào trailingRows.
|
|
579
|
+
const rule = c.dim('─'.repeat(Math.max(8, cols())));
|
|
524
580
|
const bar = renderBar();
|
|
525
|
-
if (bar)
|
|
581
|
+
if (bar) {
|
|
582
|
+
r.push(rule); // thanh chắn TRÊN (phía trên bar — không ảnh hưởng cursor)
|
|
583
|
+
r.push(...bar.split('\n'));
|
|
584
|
+
r.push(rule); // thanh chắn DƯỚI
|
|
585
|
+
trailingRows = 1; // 1 dòng sau bar → placeCursor nhảy lên thêm 1
|
|
586
|
+
} else {
|
|
587
|
+
trailingRows = 0;
|
|
588
|
+
}
|
|
526
589
|
return r;
|
|
527
590
|
}
|
|
528
591
|
function eraseSeq() {
|
|
529
592
|
if (!drawn) return '\r';
|
|
530
|
-
|
|
593
|
+
// Sau lần vẽ trước, placeCursor đã đưa con trỏ về dòng nhập logic — nằm
|
|
594
|
+
// TRÊN dòng cuối cùng đã vẽ `lastUpBy` dòng (qua thanh chắn dưới + phần bar).
|
|
595
|
+
// Để về dòng ĐẦU rồi ESC[J xóa xuống, chỉ nhảy lên (prevRows-1) - lastUpBy.
|
|
596
|
+
const up = Math.max(0, prevRows - 1 - lastUpBy);
|
|
597
|
+
return '\r' + (up > 0 ? `${ESC}[${up}A` : '') + `${ESC}[J`;
|
|
531
598
|
}
|
|
532
599
|
// Sau khi vẽ xong các hàng, con trỏ đang ở CUỐI thanh (hàng cuối, cột cuối).
|
|
533
600
|
// Đưa về đúng (row, col) của con trỏ logic: \r về cột 0 → đi lên `upBy` hàng
|
|
@@ -535,7 +602,11 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, onCtrlT, com
|
|
|
535
602
|
// upBy dùng `barRows` (số dòng BAR, set bởi renderBar) — KHÔNG dùng totalRows
|
|
536
603
|
// (gồm cả top/menu rows phía trên bar) vì sẽ kéo cursor lên quá cao.
|
|
537
604
|
const placeCursor = () => {
|
|
538
|
-
|
|
605
|
+
// upBy: số dòng cần nhảy LÊN từ dòng cuối cùng đã vẽ về dòng con trỏ logic.
|
|
606
|
+
// Dòng cuối giờ là thanh chắn DƯỚI (trailingRows), nên cộng nó vào: nhảy qua
|
|
607
|
+
// thanh chắn rồi mới tính trong phạm vi bar như cũ.
|
|
608
|
+
const upBy = trailingRows + (barRows - 1 - cursorScreenRow);
|
|
609
|
+
lastUpBy = upBy; // ghi lại để eraseSeq lần sau biết con trỏ đang ở đâu
|
|
539
610
|
let s = '\r';
|
|
540
611
|
if (upBy > 0) s += `${ESC}[${upBy}A`;
|
|
541
612
|
if (cursorScreenCol > 0) s += `${ESC}[${cursorScreenCol}C`;
|
|
@@ -543,7 +614,10 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, onCtrlT, com
|
|
|
543
614
|
};
|
|
544
615
|
function draw() {
|
|
545
616
|
const rs = rows(); // rows() → renderBar() cập nhật cursorScreenRow/Col + barRows
|
|
546
|
-
|
|
617
|
+
// Con trỏ ảo (ô đảo màu trong coloredInput) là con trỏ DUY NHẤT — giữ con trỏ
|
|
618
|
+
// phần cứng ẩn (?25l) suốt phiên, không bật lại ở cuối frame. placeCursor vẫn
|
|
619
|
+
// đưa con trỏ phần cứng về dòng nhập để eraseSeq lần sau tính đúng vùng xóa.
|
|
620
|
+
w(`${ESC}[?25l` + eraseSeq() + rs.join('\n') + placeCursor());
|
|
547
621
|
prevRows = rs.length;
|
|
548
622
|
drawn = true;
|
|
549
623
|
}
|
|
@@ -560,7 +634,8 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, onCtrlT, com
|
|
|
560
634
|
let s = `${ESC}[?25l` + eraseSeq();
|
|
561
635
|
s += block;
|
|
562
636
|
if (!block.endsWith('\n')) s += '\n';
|
|
563
|
-
|
|
637
|
+
// Con trỏ ảo là con trỏ duy nhất — không bật con trỏ phần cứng ở cuối frame.
|
|
638
|
+
s += rs.join('\n') + placeCursor();
|
|
564
639
|
w(s);
|
|
565
640
|
prevRows = rs.length;
|
|
566
641
|
drawn = true;
|
|
@@ -577,7 +652,27 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, onCtrlT, com
|
|
|
577
652
|
}
|
|
578
653
|
liveOut = buf;
|
|
579
654
|
if (done.length) commit(done.join('\n'));
|
|
580
|
-
|
|
655
|
+
// Phần dở (chưa có \n) sống trong liveOut → topRow() đẩy nó lên TOP vùng vẽ
|
|
656
|
+
// lại mỗi frame. Nếu liveOut phình dài hơn 1 dòng terminal, wrapText cho nhiều
|
|
657
|
+
// dòng → tổng rows() cao dần, vượt chiều cao terminal → terminal CUỘN, prevRows
|
|
658
|
+
// không còn khớp vị trí vật lý con trỏ → eraseSeq lần kế xóa nhầm, bar (thanh
|
|
659
|
+
// chat) bị đẩy khuất/mất. Fix: khi liveOut tràn 1 dòng, FLUSH phần đã đầy dòng
|
|
660
|
+
// thành output vĩnh viễn (commit), chỉ giữ lại đuôi ngắn (< cols) trong liveOut.
|
|
661
|
+
else if (visLen(liveOut) > cols()) {
|
|
662
|
+
const w0 = cols();
|
|
663
|
+
const wrapped = softWrapLine(liveOut, w0);
|
|
664
|
+
// Giữ dòng cuối (đuôi đang gõ tiếp) trong liveOut, commit các dòng đã đầy.
|
|
665
|
+
const tail = wrapped[wrapped.length - 1];
|
|
666
|
+
const head = wrapped.slice(0, -1).join('\n');
|
|
667
|
+
if (head) {
|
|
668
|
+
liveOut = tail.replace(ANSI_RE, '');
|
|
669
|
+
commit(head);
|
|
670
|
+
} else {
|
|
671
|
+
draw();
|
|
672
|
+
}
|
|
673
|
+
} else {
|
|
674
|
+
draw();
|
|
675
|
+
}
|
|
581
676
|
}
|
|
582
677
|
|
|
583
678
|
// ----- bàn phím: paste (bracketed) + heuristic chunk nhiều dòng -----
|
|
@@ -941,7 +1036,9 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, onCtrlT, com
|
|
|
941
1036
|
process.stdin.setEncoding('utf8');
|
|
942
1037
|
if (process.stdin.setRawMode) process.stdin.setRawMode(true);
|
|
943
1038
|
process.stdin.resume();
|
|
944
|
-
|
|
1039
|
+
// Bar cursor (DECSCUSR 6): vạch đứng hiển thị GIỮA hai ô, không đè màu lên
|
|
1040
|
+
// ký tự như block cursor → tránh ảo giác '|a' (block phủ ô trống sau text).
|
|
1041
|
+
w(`${ESC}[?2004h${ESC}[6 q`); // bật bracketed paste + bar cursor
|
|
945
1042
|
process.stdin.on('data', onData); // Shift+Tab (\x1b[Z) + mũi tên xử lý trong feedKeys
|
|
946
1043
|
// vá stdout: mọi output → commit phía trên thanh
|
|
947
1044
|
process.stdout.write = (chunk, enc, cb) => {
|
|
@@ -1046,7 +1143,9 @@ export function createTui({ onLine, onInterrupt, onEOF, onShiftTab, onCtrlT, com
|
|
|
1046
1143
|
try {
|
|
1047
1144
|
if (frameTimer) clearInterval(frameTimer);
|
|
1048
1145
|
process.stdout.write = realWrite;
|
|
1049
|
-
|
|
1146
|
+
// Tắt bracketed paste + reset cursor style về mặc định (DECSCUSR 0) +
|
|
1147
|
+
// hiện con trỏ. Không reset → bar cursor dính lại ở shell sau khi thoát.
|
|
1148
|
+
w(`${ESC}[?2004l${ESC}[0 q${ESC}[?25h\n`);
|
|
1050
1149
|
if (process.stdin.setRawMode) process.stdin.setRawMode(false);
|
|
1051
1150
|
process.stdin.pause();
|
|
1052
1151
|
} catch {}
|