@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 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
- A Claude-Code-style coding agent that lives in your terminal, powered by
4
- **Noob Demon** (34 models across 8 providers GPT, Claude, Gemini, DeepSeek,
5
- Grok, Qwen, Kimi, Llama).
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 hình flagship (Claude Opus 4.8, GPT-5.5,
5
+ DeepSeek V4 Pro).
6
6
 
7
- It reads and edits files, runs shell commands, and iterates on a taskasking
8
- permission before anything destructiveall inside a polished terminal UI.
7
+ đọ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 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 # optional: makes `noob` available globally
24
+ npm link # tùy chọn: cho phép gõ `noob` toàn cục
25
25
  ```
26
26
 
27
- Requires Node.js ≥ 18 (tested on 22).
27
+ Yêu cầu Node.js ≥ 18 (test trên 22).
28
28
 
29
- ## Xác thực & gói cước (Authentication)
29
+ ## Xác thực & gói cước
30
30
 
31
- noob đi qua một **gateway** (Cloudflare Worker `claude-code-proxy`) để ẩn backend
32
- thật và quản lý API key qua Supabase. Bạn cần một API key để dùng:
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 # interactive session
65
- noob "add input validation to api.js" # start with a request
66
- noob -m gateway-claude-opus-4-8 # pick a model
67
- noob --yolo # auto-approve edits & commands
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 hình
59
+ noob --yolo # tự duyệt edit & lệnh
68
60
  ```
69
61
 
70
- Without `npm link`, run `node bin/noob.js …`.
71
-
72
- ### In-session commands
73
-
74
- | Command | What it does |
75
- | --------------- | ------------------------------------------------------------------ |
76
- | `/model [name]` | switch model (fuzzy match), or list all |
77
- | `/models` | list every model grouped by provider |
78
- | `/merge` | toggle **Merge AI** — synthesizes GPT-5.5 + Claude + Gemini via o3 |
79
- | `/search` | toggle **web search** mode |
80
- | `/chat` | back to normal chat mode |
81
- | `/yolo` | toggle auto-approve for file/command tools (or **Shift+Tab**) |
82
- | `/clear` `/new` | wipe conversation context |
83
- | `/status` | show current model + working directory |
84
- | `/exit` | quit (Ctrl+C once = stop turn, twice = quit) |
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 hình (fuzzy match), hoặc liệt kê tất cả |
73
+ | `/models` | liệt mọi 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
- The Noob Demon gateway is a **stateless single-message** endpoint with no native
89
- function-calling. noob layers an agent on top:
108
+ Gateway Noob Demon một endpoint **stateless single-message** không
109
+ function-calling gốc. noob phủ một lớp agent lên trên:
90
110
 
91
- 1. The full transcript (system prompt + history + tool results) is serialized into
92
- one `message` and streamed to the gateway's `/api/chat`.
93
- 2. The model replies with either a final answer or a single \`\`\`tool JSON block.
94
- 3. noob parses the tool call, asks permission if it's destructive, executes it, and
95
- feeds the result back looping until the model answers without a tool block.
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 có rủi ro, thực thi, rồi feed kết quả
115
+ lạilặp tới khi model trả lời không kèm tool block.
96
116
 
97
- ### Architecture (high-level)
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
- ### Tools the agent can call
137
+ ### Tool agent gọi được
118
138
 
119
- `read_file` · `write_file` · `edit_file` · `list_dir` · `glob` · `grep` · `run_command`
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
- Destructive tools (`write_file`, `edit_file`, `run_command`) prompt for approval
122
- (`y` / `n` / `a`lways) unless `/yolo` is on.
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 | Effect |
127
- | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
128
- | `NOOB_API_BASE` | override the gateway URL |
129
- | `NOOB_API_KEY` | API key (overrides `~/.noob/config.json`) |
130
- | `NOOB_INSECURE_TLS=1` | disable TLS verification — **last resort** for machines behind a TLS-intercepting proxy. Prefer adding your proxy CA to the trust store. |
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
- The agent drives tools through a text protocol (the proxy has no native
135
- function-calling). Models differ in how willingly they follow it:
164
+ Agent điều khiển tool qua một text protocol (gateway không 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 | Notes |
167
+ | Provider | Agentic tools | Ghi chú |
138
168
  | ---------------------- | ---------------- | ---------------------------------------- |
139
- | **Anthropic** (Claude) | ✅ best | default — `gateway-claude-opus-4-8` |
140
- | **DeepSeek** | ✅ works | good free alternative |
141
- | OpenAI (GPT/o-series) | ⚠️ often refuses | replies "I can't access your filesystem" |
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
- Stick with Claude or DeepSeek for file edits & commands. Any model is fine for
145
- plain chat, `/merge`, and `/search`.
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`, `/search`.
146
175
 
147
176
  ## Project structure
148
177
 
149
178
  ```
150
179
  src/
151
- ├── api.js # gateway client + stream parser + memory token
152
- ├── agent.js # tool-loop driver, summarization, prompt assembly
153
- ├── tools.js # read/write/edit/list_dir/glob/grep/run_command + bg
154
- ├── repl.js # input loop, slash commands, session state (88 KB)
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
- │ ├── complete.js # SLASH catalog + autocomplete
157
- │ ├── todos.js # parseTodosFromHistory (pure)
158
- │ ├── ultra.js # ULTRA mode constants + prompt templates
159
- └── workflow-commands.js # /workflow help/list/load/delete (pure)
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
- ├── tui.js # terminal UI (input, status bar, prompt rendering)
162
- ├── ui.js # color helpers, markdown renderer, banner
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 # ~/.noob/skills/ user-defined skill loader
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 (o200k_base / cl100k_base)
170
- ├── models.js # 34-model catalog + fuzzy resolver
171
- ├── i18n.js # Vietnamese string table
172
- ├── update.js # `noob update` self-updater
173
- └── prompts/
174
- └── system.md # base system prompt template
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 # run vitest unit tests (96 tests across 7 files)
184
- npm run lint # eslint --check
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 gate)
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 chạm vào console. Đã có workaround trong
198
- `repl.js` (input layer tự re-arm). Nếu còn xảy ra, mở issue kèm OS + Node version.
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 bạn sau proxy chặn TLS (Zscaler, Cisco
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/Gemini từ chối tool"** — bình thường. GPT-o-series Gemini
205
- thường refuse access filesystem qua text protocol. Dùng `/model claude-opus` hoặc
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
- - Free shared proxy: rate limits and occasional hiccups are expected.
216
- - Context is sent in full each turn, so very long sessions cost more tokensuse
217
- `/clear` to reset.
218
- - This is an unofficial tool built against a public community endpoint; it is not
219
- affiliated with Anthropic or the model providers.
248
+ - Proxy chia sẻ miễn phí: rate limit đô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 tokendùng `/compact`
250
+ hoặc `/clear` để gọn lại.
251
+ - Đây 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.1",
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": "npm run lint && npm run format:check && npm run test",
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
- if (image) body.image = image;
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
- let line;
133
- if (op.type === 'add') line = chalk.green('+ ') + chalk.green(text);
134
- else if (op.type === 'del') line = chalk.red('- ') + chalk.red(text);
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.push(chalk.dim(' │ ') + chalk.green('+ ') + chalk.green(l.slice(0, 200)));
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
- const image =
1570
- opts.images?.[0] ||
1571
- imageDataUrl(files.find((f) => IMAGE_MIME[path.extname(f).toLowerCase()]));
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.${image ? ' Ảnh đầu tiên đã được đính kèm cho model vision.' : ''}]`
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 — port từ mcp-knowledge-graph, lưu .noob/kg.jsonl.
1930
- // Sub-cmd: list, path, add, obs, link, unlink, unobs, get, search, rm.
1931
- const argText = String(arg || '').trim();
1932
- const m = argText.match(/^(\S+)(?:\s+([\s\S]*))?$/);
1933
- const sub = m ? m[1].toLowerCase() : '';
1934
- const rest = m && m[2] ? m[2].trim() : '';
1935
- try {
1936
- if (!sub || sub === 'list' || sub === 'ls') {
1937
- const g = await kgLoad();
1938
- if (!g.entities.length && !g.relations.length) {
1939
- console.log(c.dim(` KG rỗng (${kgFilePath()})`));
1940
- } else {
1941
- console.log(kgFormat(g));
1942
- }
1943
- } else if (sub === 'path') {
1944
- console.log(c.dim(' ') + kgFilePath());
1945
- } else if (sub === 'add') {
1946
- const m2 = rest.match(/^(\S+)\s+(\S+)(?:\s+([\s\S]+))?$/);
1947
- if (!m2) {
1948
- console.log(c.err(' Cú pháp: /kg add <Name> <type> [<obs1>; <obs2>; ...]'));
1949
- } else {
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 = completion requirement (xem tweet_dump.txt mục "Combine
2053
- // with /goal and /loop"). Set xong sẽ inject vào MỌI prompt tới khi clear.
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
- return clip(head + text.trim());
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
- if (visLen(remaining) <= width) {
77
- lines.push(remaining);
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
- lines.push(remaining.slice(0, cutPos).trimEnd());
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
- if (visLen(remaining) <= width) {
102
- lines.push(remaining);
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
- lines.push(remaining.slice(0, cutPos).trimEnd());
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
- const coloredInput = () =>
292
- cells
293
- .map((x) => {
294
- if (x.image) return c.dim(imageLabel(x));
295
- if (x.paste === undefined) return x.c;
296
- const preview = pastePreview(x.paste);
297
- const label = preview
298
- ? `[pasted ${x.lines} lines: "${preview}"]`
299
- : `[pasted ${x.lines} lines]`;
300
- return c.dim(label);
301
- })
302
- .join('');
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
- return lines.map((l, i) => (i === 0 ? promptLabel : indent) + l).join('\n');
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) r.push(...bar.split('\n'));
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
- return '\r' + (prevRows > 1 ? `${ESC}[${prevRows - 1}A` : '') + `${ESC}[J`;
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
- const upBy = barRows - 1 - cursorScreenRow;
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
- w(`${ESC}[?25l` + eraseSeq() + rs.join('\n') + placeCursor() + `${ESC}[?25h`);
617
+ // Con trỏ ảo (ô đảo màu trong coloredInput) 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
- s += rs.join('\n') + placeCursor() + `${ESC}[?25h`;
637
+ // Con trỏ ảo 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
- else draw();
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
- w(`${ESC}[?2004h`); // bật bracketed paste
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
- w(`${ESC}[?2004l${ESC}[?25h\n`);
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 {}