@tiens.nguyen/gu-cli 1.0.892 → 1.0.894
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/README.md +59 -77
- package/client-expert-run.mjs +28 -2
- package/gonext_agent_chat.py +104 -12
- package/gu-cli.mjs +11 -1
- package/gu-repl.mjs +61 -15
- package/model-doctor.mjs +15 -2
- package/mongo-windows.mjs +63 -0
- package/package.json +3 -3
- package/python-windows.mjs +91 -0
- package/terminal-layout.mjs +102 -1
package/README.md
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
<h1 align="center">gu — the coding agent for your own models</h1>
|
|
2
2
|
|
|
3
3
|
<p align="center">
|
|
4
|
-
<strong>A coding agent in your terminal that runs on Ollama
|
|
5
|
-
It reads your repo, makes the change, runs the tests and shows you what it did
|
|
6
|
-
<strong>Free for 30 days
|
|
4
|
+
<strong>A coding agent in your terminal that runs on Ollama or any OpenAI-compatible API.</strong><br>
|
|
5
|
+
It reads your repo, makes the change, runs the tests and shows you what it did.<br>
|
|
6
|
+
<strong>Free installs run entirely on your machine. Free for 30 days: no account, no sign-in, no card.</strong>
|
|
7
7
|
</p>
|
|
8
8
|
|
|
9
9
|
<p align="center">
|
|
10
10
|
<a href="https://www.npmjs.com/package/@tiens.nguyen/gu-cli"><img alt="npm" src="https://img.shields.io/npm/v/@tiens.nguyen/gu-cli?color=6366f1&label=npm"></a>
|
|
11
11
|
<a href="https://www.npmjs.com/package/@tiens.nguyen/gu-cli"><img alt="downloads" src="https://img.shields.io/npm/dm/@tiens.nguyen/gu-cli?color=6366f1"></a>
|
|
12
|
-
<img alt="node" src="https://img.shields.io/node
|
|
12
|
+
<img alt="node 20.19+" src="https://img.shields.io/badge/node-20.19%2B-6366f1">
|
|
13
13
|
<img alt="platforms" src="https://img.shields.io/badge/macOS%20%C2%B7%20Linux%20%C2%B7%20Windows-supported-6366f1">
|
|
14
|
-
<
|
|
14
|
+
<img alt="license MIT" src="https://img.shields.io/badge/license-MIT-6366f1">
|
|
15
15
|
</p>
|
|
16
16
|
|
|
17
17
|
<p align="center">
|
|
@@ -37,18 +37,21 @@ gu
|
|
|
37
37
|
```
|
|
38
38
|
|
|
39
39
|
**Free to start, no account needed.** The first run creates a free 30-day token for this
|
|
40
|
-
computer
|
|
41
|
-
|
|
40
|
+
computer. Press Enter to use it, pick a model provider, then type what you want done.
|
|
41
|
+
|
|
42
|
+
A free install is **private**: gu's API and its database (MongoDB) run on this computer, next to
|
|
43
|
+
your code. The first run shows what it will install and asks before installing anything.
|
|
44
|
+
Needs **Node.js 20.19+**.
|
|
42
45
|
|
|
43
46
|
> **`gu: command not found`?** Run `npx @tiens.nguyen/gu-cli fix-path`. See [Troubleshooting](#troubleshooting).
|
|
44
47
|
|
|
45
48
|
## Why gu
|
|
46
49
|
|
|
47
|
-
- **Your models, your choice.** Run [Ollama](https://ollama.com)
|
|
48
|
-
|
|
50
|
+
- **Your models, your choice.** Run [Ollama](https://ollama.com) on your own machine or a GPU box
|
|
51
|
+
on your network, or bring any OpenAI-compatible provider (OpenAI, DeepSeek, OpenRouter…). Use
|
|
49
52
|
one model to plan and another to code; switch any time with `/model`.
|
|
50
|
-
- **Private by default.**
|
|
51
|
-
|
|
53
|
+
- **Private by default.** A free install keeps the agent, its API and its database on your
|
|
54
|
+
computer. Logs hold no prompts, code or keys unless you run `gu --log`.
|
|
52
55
|
- **It proves it works.** gu runs the build and the tests and fixes what fails. For web pages, the
|
|
53
56
|
`browser` tool drives a real Chrome and checks the result before gu says it is done.
|
|
54
57
|
- **It stays in the repo.** Every edit is a file you can read, diff and revert; `/undo` puts back
|
|
@@ -65,16 +68,14 @@ provider to use. Then type what you want done.
|
|
|
65
68
|
cloud, work on remote Linux boxes over SSH, or want a coding agent that costs $4 a month instead
|
|
66
69
|
of a model subscription.
|
|
67
70
|
|
|
68
|
-
**
|
|
69
|
-
|
|
70
|
-
through an OpenAI-compatible API, but its strength is everything else.
|
|
71
|
+
**Want a frontier model?** gu runs those too, through any OpenAI-compatible API, and keeps
|
|
72
|
+
everything else: the edits in your repo, the tests, the browser check and `/undo`.
|
|
71
73
|
|
|
72
74
|
## Quick start
|
|
73
75
|
|
|
74
76
|
1. **Start gu.** Run `gu` in a project folder. Your free 30-day token is created on the spot:
|
|
75
77
|
no sign-up, no browser, no card.
|
|
76
78
|
2. **Check your models.** `gu doctor` sets up and tests the models on this machine.
|
|
77
|
-
(`gu doctor --client` installs only the agent's dependencies, when the models run elsewhere.)
|
|
78
79
|
3. **Ask.** Type what you want done. Conversations are saved per folder and resumed next time;
|
|
79
80
|
`/new` starts a fresh one.
|
|
80
81
|
|
|
@@ -86,9 +87,10 @@ gu uses up to three models, each with its own command:
|
|
|
86
87
|
| --- | --- | --- |
|
|
87
88
|
| `/model` | **coding**: writes the code | this session (`/quick-models` keeps 2 to flip with ← / →) |
|
|
88
89
|
| `/agent-model` | **agent**: talks to you and plans | this machine |
|
|
89
|
-
| `/embed-model` | **embedding**: RAG (`nomic-embed-text`, `qwen3-embedding`), local or remote | this machine
|
|
90
|
+
| `/embed-model` | **embedding**: RAG (`nomic-embed-text`, `qwen3-embedding`), local or remote | this machine |
|
|
90
91
|
|
|
91
|
-
Run `/doctor` any time to check that each one answers.
|
|
92
|
+
Run `/doctor` any time to check that each one answers. Coding works best with a strong model:
|
|
93
|
+
small local models often fail at multi-step changes.
|
|
92
94
|
|
|
93
95
|
## Using gu
|
|
94
96
|
|
|
@@ -102,10 +104,23 @@ gu [-v|--verbose] [--log] [--color] [--name <text>] [--license <pro token>]
|
|
|
102
104
|
| `--log` | log this run in detail to `~/.gonext/worker.log` (default: starts, stops and errors only) |
|
|
103
105
|
| `--color` | colour the live thought and the thinking spinner |
|
|
104
106
|
| `--name <text>` | show `<text>` instead of the gu logo in the banner (this run only) |
|
|
105
|
-
| `--license <token>` |
|
|
107
|
+
| `--license <token>` | use a Pro licence on first install |
|
|
106
108
|
|
|
107
109
|
Ctrl-C stops the running turn. Type `/` at the prompt to see every command; Tab completes.
|
|
108
110
|
|
|
111
|
+
**The commands you'll use most**
|
|
112
|
+
|
|
113
|
+
| Command | What it does |
|
|
114
|
+
| --- | --- |
|
|
115
|
+
| `/model` | switch the coding model |
|
|
116
|
+
| `/new` | start a new conversation in this folder |
|
|
117
|
+
| `/undo` | put back the files the last stopped turn changed (asks first) |
|
|
118
|
+
| `/test-auto` | for this folder: verify and fix until the tests pass |
|
|
119
|
+
| `/browser` | let the agent check web pages in a real Chrome |
|
|
120
|
+
| `/mcp` | add MCP servers, and choose which tools run without asking |
|
|
121
|
+
| `/doctor` | check that every model answers |
|
|
122
|
+
| `/support` | send a request with gu's logs attached (secrets masked; shows you first) |
|
|
123
|
+
|
|
109
124
|
<details>
|
|
110
125
|
<summary><strong>All slash commands</strong></summary>
|
|
111
126
|
|
|
@@ -117,28 +132,24 @@ Ctrl-C stops the running turn. Type `/` at the prompt to see every command; Tab
|
|
|
117
132
|
| `/new` | start a new conversation in this folder (the old one is archived) |
|
|
118
133
|
| `/undo [runId]` | put back the files the last stopped turn changed, from its backups (asks first) |
|
|
119
134
|
| `/test-auto` | toggle auto-test for this folder: verify and fix until it passes |
|
|
120
|
-
| `/browser [allow\|forget\|install\|cleanup]` | the agent's web browser, through the gu Chrome extension or gu's own Chrome window
|
|
135
|
+
| `/browser [allow\|forget\|install\|cleanup]` | the agent's web browser, through the gu Chrome extension or gu's own Chrome window |
|
|
121
136
|
| `/shells` | long commands the agent left running in the background: status, output, stop one |
|
|
122
|
-
| `/rag` | where this folder's RAG index lives
|
|
137
|
+
| `/rag` | where this folder's RAG index lives |
|
|
123
138
|
| `/tokens` | break down the last prompt sent to the coding model, including how much was RAG |
|
|
124
139
|
| `/prices [update]` | price tables, and what the last turn and this session cost |
|
|
125
140
|
| `/budget` | your API provider budgets and balances (Pro) |
|
|
126
141
|
| `/mcp [list\|tools\|add\|remove\|enable\|disable\|approve\|allow\|disallow\|reconnect]` | MCP servers, and which of their tools run without asking |
|
|
127
142
|
| `/hooks [list\|approve\|unapprove]` | commands gu runs on `onStart`, `onAsk`, `onToolDone`, `onTurnEnd` |
|
|
128
143
|
| `/switch-skill [claude\|gu]` | use Claude Code's (`.claude`) or gu's (`.gonext`) skills, MCP servers and hooks in this folder |
|
|
129
|
-
| `/skill-pack <name
|
|
130
|
-
| `/
|
|
131
|
-
| `/resume` | carry on the last unfinished task in this folder, Claude Code's or gu's: shows its plan, last command and next step, then asks (needs the memory skill) |
|
|
132
|
-
| `/skills-copy-to-claude [--dry-run] [name…]` | copy gu's skills into Claude Code's folders |
|
|
144
|
+
| `/skill-pack <name>`, `/skill-install <zip\|folder>` | pack a skill with its MCP servers and hooks into one zip, and install it on another machine (no secrets) |
|
|
145
|
+
| `/resume` | carry on the last unfinished task in this folder, Claude Code's or gu's |
|
|
133
146
|
| `/proxy [show\|set\|on\|off\|clear\|test]` | route model calls through an HTTP proxy (local calls go direct) |
|
|
134
147
|
| `/s3 [test]` | use your own S3 bucket for the RAG index and generated files |
|
|
135
|
-
| `/server` | pick a deployment server (host/user) for this session |
|
|
136
|
-
| `/host-mode` | undo client mode: run agent turns on this machine again |
|
|
137
148
|
| `/save-settings`, `/load-settings` | move providers, models and key *names* (no secrets) to another machine |
|
|
138
149
|
| `/save-preset`, `/open-presets`, `/use-presets` | your whole setup, keys included, to copy, edit and apply (backs up first) |
|
|
139
|
-
| `/clear-logs` | show what logs
|
|
140
|
-
| `/voucher CODE` |
|
|
141
|
-
| `/support` | send the support team a request with gu's logs attached
|
|
150
|
+
| `/clear-logs` | show what logs and indexes take on disk, then ask what to clear |
|
|
151
|
+
| `/voucher CODE` | add free days to this computer's trial or your free account |
|
|
152
|
+
| `/support` | send the support team a request with gu's logs attached |
|
|
142
153
|
| `/help`, `/exit` | list commands / quit |
|
|
143
154
|
|
|
144
155
|
Each skill in `~/.gonext/skills/<name>/SKILL.md` (or the folder's `.gonext/skills`) is a slash command too.
|
|
@@ -149,32 +160,25 @@ Each skill in `~/.gonext/skills/<name>/SKILL.md` (or the folder's `.gonext/skill
|
|
|
149
160
|
<summary><strong>Shell commands</strong></summary>
|
|
150
161
|
|
|
151
162
|
```
|
|
152
|
-
gu status
|
|
153
|
-
gu start | stop | restart the background worker
|
|
154
|
-
gu logs [--lines N] [-f]
|
|
155
|
-
gu doctor
|
|
156
|
-
gu rag [plan [path] | --test] test the embedder,
|
|
157
|
-
gu api <start|stop|restart|status|upgrade> the local API (private machines)
|
|
163
|
+
gu status is gu running, and which model servers
|
|
164
|
+
gu start | stop | restart the background worker
|
|
165
|
+
gu logs [--lines N] [-f] follow gu's log
|
|
166
|
+
gu doctor set up and check models on this machine
|
|
167
|
+
gu rag [plan [path] | --test] test the embedder, estimate index time
|
|
158
168
|
gu workspace revert [runId] undo an agent run's file edits from its backups
|
|
159
169
|
gu support send a support request with masked logs
|
|
160
|
-
gu
|
|
161
|
-
gu mongo migrate-from-docker macOS: move a Docker MongoDB to Homebrew
|
|
162
|
-
gu clear [--scratch|--keep-history] [--yes] back to a fresh machine (asks first)
|
|
170
|
+
gu clear [--yes] back to a fresh machine (asks first)
|
|
163
171
|
gu fix-path | fix-powershell repair PATH / remove PowerShell's `gu` alias
|
|
164
172
|
```
|
|
165
173
|
|
|
166
|
-
`gu-cli` takes the same commands, plus `login [--api-base <url>]`, `set <workerKey>`,
|
|
167
|
-
`token [<token>]`, `ws-ping-test` and `simulate-chat [text…]`.
|
|
168
|
-
|
|
169
174
|
</details>
|
|
170
175
|
|
|
171
176
|
## Where gu keeps things
|
|
172
177
|
|
|
173
178
|
| Path | Contents |
|
|
174
179
|
| --- | --- |
|
|
175
|
-
| `~/.gonext/worker.env` | sign-in: worker key and API address |
|
|
176
180
|
| `~/.gonext/sessions/` | conversation history per folder (`archive/` holds old ones) |
|
|
177
|
-
| `~/.gonext/worker.log
|
|
181
|
+
| `~/.gonext/worker.log` | log |
|
|
178
182
|
| `~/.gonext/mcp.json` | MCP servers and their allow rules |
|
|
179
183
|
| `~/.gonext/skills/`, `<folder>/.gonext/skills/` | skills: everywhere / this folder only |
|
|
180
184
|
|
|
@@ -182,57 +186,35 @@ gu fix-path | fix-powershell repair PATH / remove PowerShell's `gu` alias
|
|
|
182
186
|
|
|
183
187
|
| | How long | What you need |
|
|
184
188
|
| --- | --- | --- |
|
|
185
|
-
| **Free trial** | 30 days,
|
|
189
|
+
| **Free trial** | 30 days, from the first run | nothing: the token is created for you |
|
|
186
190
|
| **Free plan** | 3 more months on that computer | a free account at [gucli.com](https://gucli.com) |
|
|
187
|
-
| **Pro** |
|
|
191
|
+
| **Pro** | **$4 a month** or **$40 a year** (+ VAT where it applies) | hosted models (no model server to run), up to 5 computers, provider budgets |
|
|
192
|
+
|
|
193
|
+
On Pro, the hosted models and the hosted API run on gu's servers, so the prompts and code you
|
|
194
|
+
send through them go there. A free install keeps everything on your computer.
|
|
188
195
|
|
|
189
196
|
Got a **voucher code**? Type `/voucher CODE` in gu (or enter it on your
|
|
190
|
-
[account page](https://gucli.com/account)) for extra free days
|
|
197
|
+
[account page](https://gucli.com/account)) for extra free days.
|
|
191
198
|
|
|
192
199
|
Free time belongs to the computer: reinstalling doesn't restart it. Already have a token? Paste
|
|
193
|
-
it at the first-run question
|
|
200
|
+
it at the first-run question. Current prices: [gucli.com/pricing](https://gucli.com/pricing).
|
|
194
201
|
|
|
195
202
|
## Troubleshooting
|
|
196
203
|
|
|
197
204
|
**`gu: command not found` after install.** npm 11 skips install scripts it wasn't told to allow,
|
|
198
205
|
so the step that adds npm's bin folder to your PATH may not have run. Run
|
|
199
206
|
`npx @tiens.nguyen/gu-cli fix-path`. It also finds what an old install left behind (broken links,
|
|
200
|
-
a second gu from another npm, a PATH line
|
|
201
|
-
fixing each one. `--check` only reports.
|
|
207
|
+
a second gu from another npm, a stale PATH line) and asks before fixing each one. `--check` only reports.
|
|
202
208
|
|
|
203
209
|
**Windows: `gu` runs `Get-Unique`.** PowerShell has a built-in `gu` alias. Run `gu-cli fix-powershell`.
|
|
204
210
|
|
|
205
|
-
**Node version.** Node.js
|
|
206
|
-
computer) needs **Node.js 20.19+**.
|
|
211
|
+
**Node version.** gu needs **Node.js 20.19+** (a Pro install using only hosted models runs on 18+).
|
|
207
212
|
|
|
208
213
|
**A change in a model or setting seems ignored.** `gu doctor` shows the model and URL actually in use.
|
|
209
214
|
|
|
210
|
-
**Still stuck?** Run `gu support` (or `/support` in gu
|
|
211
|
-
|
|
212
|
-
before sending.
|
|
213
|
-
|
|
214
|
-
<details>
|
|
215
|
-
<summary><strong>Advanced: worker jobs and environment variables</strong></summary>
|
|
216
|
-
|
|
217
|
-
The background worker also runs jobs queued from the [web app](https://gucli.com) on this machine:
|
|
218
|
-
|
|
219
|
-
- **Agent chat**: choosing **Agent** in the web app runs a
|
|
220
|
-
[smolagents](https://github.com/huggingface/smolagents) agent on your local model
|
|
221
|
-
(`pip install smolagents certifi`). Cloud models are not allowed here; pick a local MLX or Ollama model.
|
|
222
|
-
- **API checks**: the worker makes the HTTP request itself, measures status, latency and body,
|
|
223
|
-
and a local model writes a one-line summary.
|
|
224
|
-
|
|
225
|
-
| Variable | Meaning |
|
|
226
|
-
| --- | --- |
|
|
227
|
-
| `GONEXT_API_BASE`, `GONEXT_WORKER_KEY` | API address and worker key (normally from `~/.gonext/worker.env`) |
|
|
228
|
-
| `GONEXT_PROBE_PYTHON` | Python for agent scripts (default: `GONEXT_MLX_LM_PYTHON` or `python3`) |
|
|
229
|
-
| `GONEXT_MLX_LM_PYTHON` | Python for the MLX probe (default `python3`) |
|
|
230
|
-
| `GONEXT_OCR_MODEL_PATH` | local GLM-OCR directory (default `~/mlx-models/GLM-OCR-bf16`) |
|
|
231
|
-
| `GONEXT_OCR_DEBUG=1` | print raw OCR output |
|
|
232
|
-
| `GONEXT_SIMULATE_TEXT` | default reply for `simulate-chat` |
|
|
233
|
-
|
|
234
|
-
</details>
|
|
215
|
+
**Still stuck?** Run `gu support` (or `/support` in gu). It sends the team your request with gu's
|
|
216
|
+
logs attached, secrets masked, and shows you what goes before sending.
|
|
235
217
|
|
|
236
218
|
## License
|
|
237
219
|
|
|
238
|
-
|
|
220
|
+
MIT © Tien Nguyen Song
|
package/client-expert-run.mjs
CHANGED
|
@@ -21,7 +21,7 @@ import { join } from "node:path";
|
|
|
21
21
|
import { promisify } from "node:util";
|
|
22
22
|
import { createHash } from "node:crypto";
|
|
23
23
|
import { localModeNodeProblem } from "./node-version.mjs";
|
|
24
|
-
import { downloadMongodExe, startPortableMongod, portableMongoInstalled, mongoWinPaths, mongodCommandLine, MONGO_WIN_VERSION } from "./mongo-windows.mjs";
|
|
24
|
+
import { downloadMongodExe, startPortableMongod, portableMongoInstalled, mongoWinPaths, mongodCommandLine, MONGO_WIN_VERSION, mongodRuns, isDllNotFoundExit, installVcRuntime, VC_REDIST_URL } from "./mongo-windows.mjs";
|
|
25
25
|
import {
|
|
26
26
|
apiDepsInstalled,
|
|
27
27
|
installApiDeps,
|
|
@@ -415,7 +415,7 @@ export function runInTerminal(cmd, { platform = process.platform, spawnFn = spaw
|
|
|
415
415
|
* administrator prompt. Shown, asked, then done, like the other platforms. If the download fails
|
|
416
416
|
* the winget commands are printed as the way out.
|
|
417
417
|
*/
|
|
418
|
-
export async function ensureNativeMongoWindows(ask, mongo, { log = (l) => console.log(l), plan = null, waitFor = null, download = downloadMongodExe, start = startPortableMongod } = {}) {
|
|
418
|
+
export async function ensureNativeMongoWindows(ask, mongo, { log = (l) => console.log(l), plan = null, waitFor = null, download = downloadMongodExe, start = startPortableMongod, runs = mongodRuns, installRuntime = installVcRuntime } = {}) {
|
|
419
419
|
const installing = !mongo.installed;
|
|
420
420
|
const paths = mongoWinPaths();
|
|
421
421
|
const { host, port } = mongoHostPort(LOCAL_MONGO_URI);
|
|
@@ -445,6 +445,32 @@ export async function ensureNativeMongoWindows(ask, mongo, { log = (l) => consol
|
|
|
445
445
|
return { ok: false, explained: true };
|
|
446
446
|
}
|
|
447
447
|
}
|
|
448
|
+
// A new Windows 11 has no Visual C++ runtime, and mongod.exe then dies at once with 0xC0000135 — started
|
|
449
|
+
// detached, that shows up only as "not answering". Ask mongod itself first, and say the real reason (GH #363).
|
|
450
|
+
const probe = await runs(paths);
|
|
451
|
+
if (!probe.ok && isDllNotFoundExit(probe.code)) {
|
|
452
|
+
log("\n " + red("✗ ") + "MongoDB cannot start: this Windows is missing Microsoft's Visual C++ runtime");
|
|
453
|
+
log(dim(" (mongod.exe stops with 0xC0000135, \"a DLL was not found\"). It is a free Microsoft download, about 25 MB;"));
|
|
454
|
+
log(dim(" installing it asks for administrator permission once. " + VC_REDIST_URL));
|
|
455
|
+
const ok = await readYesNo(ask, " Install the Visual C++ runtime now? [Y/n]: ", true, log);
|
|
456
|
+
if (!ok) {
|
|
457
|
+
log(dim("\n Not installed — install it from the link above, then run gu again."));
|
|
458
|
+
return { ok: false, explained: true };
|
|
459
|
+
}
|
|
460
|
+
log("\n " + bold("Installing the Visual C++ runtime"));
|
|
461
|
+
const vc = await installRuntime({});
|
|
462
|
+
if (!vc.ok) {
|
|
463
|
+
log("\n " + red("✗ ") + `could not install the Visual C++ runtime: ${vc.error ?? "unknown error"}`);
|
|
464
|
+
log(dim(" Install it yourself from " + VC_REDIST_URL + " , then run gu again."));
|
|
465
|
+
return { ok: false, explained: true };
|
|
466
|
+
}
|
|
467
|
+
log(" " + cyan("✓ Visual C++ runtime installed"));
|
|
468
|
+
const again = await runs(paths);
|
|
469
|
+
if (!again.ok) {
|
|
470
|
+
log("\n " + red("✗ ") + "mongod.exe still cannot run" + (again.code != null ? ` (exit code ${again.code})` : "") + ". A restart of Windows may be needed; then run gu again.");
|
|
471
|
+
return { ok: false, explained: true };
|
|
472
|
+
}
|
|
473
|
+
}
|
|
448
474
|
let started;
|
|
449
475
|
try {
|
|
450
476
|
started = await start({ port, paths });
|
package/gonext_agent_chat.py
CHANGED
|
@@ -2800,7 +2800,7 @@ def _lexical_fallback_search(query: str, files, max_files: int = 8, per_file: in
|
|
|
2800
2800
|
total = 0
|
|
2801
2801
|
for abs_path, _rel in files:
|
|
2802
2802
|
try:
|
|
2803
|
-
with
|
|
2803
|
+
with _open_project_text(abs_path) as fh:
|
|
2804
2804
|
found, hits, lines = set(), 0, []
|
|
2805
2805
|
for i, line in enumerate(fh, 1):
|
|
2806
2806
|
matched = [t for t, rx in rxs if rx.search(line)]
|
|
@@ -5798,11 +5798,23 @@ _RAG_TEXT_EXTS = {
|
|
|
5798
5798
|
".html", ".htm", ".css", ".scss", ".sass", ".less", ".xml", ".yaml", ".yml",
|
|
5799
5799
|
".toml", ".ini", ".cfg", ".conf", ".env", ".sh", ".bash", ".zsh", ".sql",
|
|
5800
5800
|
".graphql", ".proto", ".gradle", ".dockerfile", ".tsv", ".csv",
|
|
5801
|
+
# WINDOWS / .NET (#490). This list is also what grep_repo can SEE, so a file type missing
|
|
5802
|
+
# here is invisible to search and RAG alike — on a Windows project that was the scripts,
|
|
5803
|
+
# the project files and half the UI: a .csproj, a web.config, a .xaml view, a .ps1 build
|
|
5804
|
+
# step could not be found by any tool, and nothing said so.
|
|
5805
|
+
".ps1", ".psm1", ".psd1", ".bat", ".cmd", ".vb", ".fs", ".fsx", ".razor", ".cshtml",
|
|
5806
|
+
".vbhtml", ".xaml", ".axaml", ".csproj", ".vbproj", ".fsproj", ".vcxproj", ".sln",
|
|
5807
|
+
".props", ".targets", ".config", ".resx", ".manifest", ".reg",
|
|
5808
|
+
# Other common source types the list never had.
|
|
5809
|
+
".dart", ".scala", ".lua", ".pl", ".pm", ".r", ".ex", ".exs", ".erl", ".hs", ".clj",
|
|
5810
|
+
".m", ".mm", ".tf", ".hcl", ".cmake", ".groovy", ".kts", ".astro", ".prisma",
|
|
5801
5811
|
}
|
|
5802
5812
|
_RAG_SKIP_DIRS = {
|
|
5803
5813
|
"node_modules", ".git", ".svn", ".hg", "__pycache__", ".venv", "venv",
|
|
5804
5814
|
"dist", "build", ".next", ".nuxt", "out", ".cache", "bin", "obj", ".idea",
|
|
5805
5815
|
".gradle", "vendor", "target",
|
|
5816
|
+
# Visual Studio's per-machine cache (IntelliSense DBs, solution state) — never project code.
|
|
5817
|
+
".vs",
|
|
5806
5818
|
# ANOTHER AGENT'S SESSION LOG IS NOT PROJECT STATE. Reported live: asked to "continue",
|
|
5807
5819
|
# the agent listed .claude-memory/, read latest-checkpoint.md, and reasoned from it —
|
|
5808
5820
|
# "a partial session about d365-sharepoint-upload … 25 edits … resume from memory" — as if
|
|
@@ -6480,6 +6492,33 @@ def _rag_unzip(zip_path: str, dest_dir: str = "") -> tuple:
|
|
|
6480
6492
|
return dest_dir, names, False
|
|
6481
6493
|
|
|
6482
6494
|
|
|
6495
|
+
def _sniff_text_encoding(path: str) -> str:
|
|
6496
|
+
"""The encoding to read a project file with: a BOM decides, else UTF-8.
|
|
6497
|
+
|
|
6498
|
+
WINDOWS WRITES UTF-16 (#490). Windows PowerShell 5.1's `>` and Out-File write UTF-16LE, and
|
|
6499
|
+
some Visual Studio / Notepad files are UTF-16 too. Read as UTF-8, every character carries a
|
|
6500
|
+
NUL after it, so no pattern ever matched and the index embedded noise. UTF-8 WITH a BOM
|
|
6501
|
+
(Visual Studio's default for .cs) read fine except line 1, which began with U+FEFF and so
|
|
6502
|
+
never matched `^using`. Only a BOM is trusted: guessing UTF-16 from NUL counts would also
|
|
6503
|
+
"detect" it in binary files.
|
|
6504
|
+
"""
|
|
6505
|
+
try:
|
|
6506
|
+
with open(path, "rb") as fh:
|
|
6507
|
+
head = fh.read(4)
|
|
6508
|
+
except OSError:
|
|
6509
|
+
return "utf-8"
|
|
6510
|
+
if head.startswith(b"\xef\xbb\xbf"):
|
|
6511
|
+
return "utf-8-sig"
|
|
6512
|
+
if head.startswith(b"\xff\xfe") or head.startswith(b"\xfe\xff"):
|
|
6513
|
+
return "utf-16" # the codec reads the BOM and picks LE/BE
|
|
6514
|
+
return "utf-8"
|
|
6515
|
+
|
|
6516
|
+
|
|
6517
|
+
def _open_project_text(path: str):
|
|
6518
|
+
"""Open a project file for READING ONLY, in the encoding its BOM names (see above)."""
|
|
6519
|
+
return open(path, "r", encoding=_sniff_text_encoding(path), errors="replace")
|
|
6520
|
+
|
|
6521
|
+
|
|
6483
6522
|
def _rag_iter_text_files(root: str):
|
|
6484
6523
|
"""Yield (abs_path, rel_path, ext) for indexable text files under root."""
|
|
6485
6524
|
import os
|
|
@@ -6501,7 +6540,30 @@ def _rag_iter_text_files(root: str):
|
|
|
6501
6540
|
continue
|
|
6502
6541
|
except OSError:
|
|
6503
6542
|
continue
|
|
6504
|
-
|
|
6543
|
+
# "/" ON EVERY OS (#490). This is the RAG index's key for the file, and what search
|
|
6544
|
+
# results show. With os.sep it was `src\a.py` on Windows and `src/a.py` elsewhere,
|
|
6545
|
+
# so an index could not be shared between the two, and results printed differently
|
|
6546
|
+
# per machine. Windows accepts "/" in every path the tools take.
|
|
6547
|
+
yield abs_path, os.path.relpath(abs_path, root).replace(os.sep, "/"), ext
|
|
6548
|
+
|
|
6549
|
+
|
|
6550
|
+
def _rag_posix_chunk(c: dict, sep: str = "") -> dict:
|
|
6551
|
+
"""Move a chunk from an index built with `\\` paths (Windows, before #490) to `/` paths.
|
|
6552
|
+
|
|
6553
|
+
In place and without re-embedding: only the file key and the location line at the top of
|
|
6554
|
+
its text change, so an existing Windows index keeps its vectors instead of re-embedding
|
|
6555
|
+
the whole project once. A no-op off Windows, where `\\` can be part of a real file name.
|
|
6556
|
+
"""
|
|
6557
|
+
if (sep or os.sep) != "\\":
|
|
6558
|
+
return c
|
|
6559
|
+
f = c.get("file")
|
|
6560
|
+
if isinstance(f, str) and "\\" in f:
|
|
6561
|
+
new = f.replace("\\", "/")
|
|
6562
|
+
t = c.get("text")
|
|
6563
|
+
if isinstance(t, str) and t.startswith(f + ":"):
|
|
6564
|
+
c["text"] = new + t[len(f):]
|
|
6565
|
+
c["file"] = new
|
|
6566
|
+
return c
|
|
6505
6567
|
|
|
6506
6568
|
|
|
6507
6569
|
# A line that STARTS a definition — where a chunk should be cut, and what names it. Kept
|
|
@@ -8376,6 +8438,30 @@ def _pipe_hidden_failure_note(line: str, ps: bool = False) -> str:
|
|
|
8376
8438
|
+ ("." if ps else ", or start the line with `set -o pipefail;`."))
|
|
8377
8439
|
|
|
8378
8440
|
|
|
8441
|
+
# A UNIX SEARCH COMMAND IN POWERSHELL (#490). Windows has no grep/rg/xargs, and its `find` is
|
|
8442
|
+
# FIND.EXE — a different program that answers `find . -name x` with "Parameter format not
|
|
8443
|
+
# correct". The model learned search as a shell habit, so it reaches for these anyway; the
|
|
8444
|
+
# error alone sends it to guess at Select-String syntax next. grep_repo / list_dir do the same
|
|
8445
|
+
# job inside gu on every OS, so the failure names them.
|
|
8446
|
+
_PS_NO_SEARCH_TOOL_RE = re.compile(
|
|
8447
|
+
r"The term '(grep|egrep|fgrep|rg|ag|ack|xargs|locate|fd)' is not recognized"
|
|
8448
|
+
r"|^(FIND): (?:Parameter format not correct|Invalid switch)",
|
|
8449
|
+
re.M | re.I)
|
|
8450
|
+
|
|
8451
|
+
|
|
8452
|
+
def _ps_search_tool_hint(out: str) -> str:
|
|
8453
|
+
"""A pointer to grep_repo when PowerShell could not run a Unix search command, else ""."""
|
|
8454
|
+
m = _PS_NO_SEARCH_TOOL_RE.search(out or "")
|
|
8455
|
+
if not m:
|
|
8456
|
+
return ""
|
|
8457
|
+
prog = (m.group(1) or m.group(2) or "").lower()
|
|
8458
|
+
what = ("`find` here is Windows' FIND.EXE, not Unix find" if prog == "find"
|
|
8459
|
+
else f"`{prog}` does not exist on Windows")
|
|
8460
|
+
return (f"\n\nNOTE — {what}. Search file CONTENTS with grep_repo(pattern, path, glob) "
|
|
8461
|
+
"and list files with list_dir(path); both run inside gu on every OS. Do not retry "
|
|
8462
|
+
"with Select-String or Get-ChildItem -Recurse.")
|
|
8463
|
+
|
|
8464
|
+
|
|
8379
8465
|
# THE PROJECT'S OWN TEST COMMANDS (#360 review). The model guessed `python3 -m pytest` in a repo
|
|
8380
8466
|
# whose package.json says `python3 -m unittest discover tests -v && node --test` — pytest was not
|
|
8381
8467
|
# even installed. The manifests already say how each part of the project is tested; reading them
|
|
@@ -9320,11 +9406,15 @@ def _ws_write_file(path: str, content: str, action: str, backup) -> None:
|
|
|
9320
9406
|
import tempfile
|
|
9321
9407
|
_ws_record_change(action, path, backup)
|
|
9322
9408
|
target = os.path.realpath(path)
|
|
9409
|
+
# KEEP THE FILE'S ENCODING (#490). Reads decode a BOM'd UTF-8 or UTF-16 file, so an edit
|
|
9410
|
+
# must write it back the same way — a UTF-16 .resx rewritten as UTF-8 is a different file to
|
|
9411
|
+
# Visual Studio, and dropping a BOM shows up as a whole-file diff. No BOM = UTF-8, as before.
|
|
9412
|
+
enc = _sniff_text_encoding(target) if os.path.isfile(target) else "utf-8"
|
|
9323
9413
|
_STOP["writing"] += 1
|
|
9324
9414
|
try:
|
|
9325
9415
|
fd, tmp = tempfile.mkstemp(prefix=".gu-write-", suffix=".tmp", dir=os.path.dirname(target) or ".")
|
|
9326
9416
|
try:
|
|
9327
|
-
with os.fdopen(fd, "w", encoding=
|
|
9417
|
+
with os.fdopen(fd, "w", encoding=enc) as fh:
|
|
9328
9418
|
fh.write(content)
|
|
9329
9419
|
fh.flush()
|
|
9330
9420
|
os.fsync(fh.fileno())
|
|
@@ -9340,7 +9430,7 @@ def _ws_write_file(path: str, content: str, action: str, backup) -> None:
|
|
|
9340
9430
|
except PermissionError:
|
|
9341
9431
|
if not _IS_WINDOWS:
|
|
9342
9432
|
raise
|
|
9343
|
-
with open(target, "w", encoding=
|
|
9433
|
+
with open(target, "w", encoding=enc) as fh:
|
|
9344
9434
|
fh.write(content)
|
|
9345
9435
|
os.unlink(tmp)
|
|
9346
9436
|
_agent_created_add(target)
|
|
@@ -14480,7 +14570,7 @@ def run_agent_chat(cfg):
|
|
|
14480
14570
|
_emit_read("Reading file", path, "skipped — already read, unchanged")
|
|
14481
14571
|
_last_obs["text"] = _rb
|
|
14482
14572
|
return _rb
|
|
14483
|
-
with
|
|
14573
|
+
with _open_project_text(rp) as fh:
|
|
14484
14574
|
data = fh.read(max(500, min(int(max_chars or 20000), 100000)))
|
|
14485
14575
|
out = _ws_cap_obs(data or "(empty file)", cap=_OBS_CAPS["read"])
|
|
14486
14576
|
_emit_read("Reading file", path, f"{data.count(chr(10)) + 1} line(s)"
|
|
@@ -14546,7 +14636,7 @@ def run_agent_chat(cfg):
|
|
|
14546
14636
|
# drop what was deleted. Nothing extra is needed to catch the agent's OWN edits:
|
|
14547
14637
|
# writing a file changes its mtime.
|
|
14548
14638
|
import os as _os_idx
|
|
14549
|
-
_prev = _rag_load_all(source_key) if not replaced else []
|
|
14639
|
+
_prev = [_rag_posix_chunk(_c) for _c in (_rag_load_all(source_key) if not replaced else [])]
|
|
14550
14640
|
_prev_stamp = {}
|
|
14551
14641
|
for _c in _prev:
|
|
14552
14642
|
_st = _c.get("stamp")
|
|
@@ -14576,7 +14666,7 @@ def run_agent_chat(cfg):
|
|
|
14576
14666
|
unchanged_files += 1
|
|
14577
14667
|
continue
|
|
14578
14668
|
try:
|
|
14579
|
-
with
|
|
14669
|
+
with _open_project_text(abs_path) as fh:
|
|
14580
14670
|
text = fh.read()
|
|
14581
14671
|
except OSError:
|
|
14582
14672
|
skipped += 1
|
|
@@ -15169,7 +15259,7 @@ def run_agent_chat(cfg):
|
|
|
15169
15259
|
and not fnmatch.fnmatch(rel, glob):
|
|
15170
15260
|
continue
|
|
15171
15261
|
try:
|
|
15172
|
-
with
|
|
15262
|
+
with _open_project_text(abs_path) as fh:
|
|
15173
15263
|
for i, line in enumerate(fh, 1):
|
|
15174
15264
|
if rx.search(line):
|
|
15175
15265
|
# ABSOLUTE path — read_file_lines/read_text_file resolve
|
|
@@ -15220,7 +15310,7 @@ def run_agent_chat(cfg):
|
|
|
15220
15310
|
defs, calls, tests = [], [], []
|
|
15221
15311
|
for abs_path, rel, _ext in _rag_iter_text_files(root):
|
|
15222
15312
|
try:
|
|
15223
|
-
with
|
|
15313
|
+
with _open_project_text(abs_path) as fh:
|
|
15224
15314
|
text = fh.read()
|
|
15225
15315
|
except OSError:
|
|
15226
15316
|
continue
|
|
@@ -15302,7 +15392,7 @@ def run_agent_chat(cfg):
|
|
|
15302
15392
|
start_line=int(start_line or 1),
|
|
15303
15393
|
end_line=int(end_line or 0)))
|
|
15304
15394
|
rp = _rag_read_allowed((path or "").strip())
|
|
15305
|
-
with
|
|
15395
|
+
with _open_project_text(rp) as fh:
|
|
15306
15396
|
lines = fh.readlines()
|
|
15307
15397
|
s = max(1, int(start_line or 1))
|
|
15308
15398
|
e = int(end_line or 0) or len(lines)
|
|
@@ -15350,7 +15440,7 @@ def run_agent_chat(cfg):
|
|
|
15350
15440
|
_last_obs["text"] = msg
|
|
15351
15441
|
return msg
|
|
15352
15442
|
backup = _ws_snapshot(rp)
|
|
15353
|
-
with
|
|
15443
|
+
with _open_project_text(rp) as fh:
|
|
15354
15444
|
lines = fh.readlines()
|
|
15355
15445
|
s, e = int(start_line), int(end_line)
|
|
15356
15446
|
if not (1 <= s <= e <= len(lines)):
|
|
@@ -15403,7 +15493,7 @@ def run_agent_chat(cfg):
|
|
|
15403
15493
|
msg = f"Error: not a file: {path}"
|
|
15404
15494
|
_last_obs["text"] = msg
|
|
15405
15495
|
return msg
|
|
15406
|
-
with
|
|
15496
|
+
with _open_project_text(rp) as fh:
|
|
15407
15497
|
text = fh.read()
|
|
15408
15498
|
n = text.count(old_string)
|
|
15409
15499
|
if n == 0:
|
|
@@ -15964,6 +16054,8 @@ def run_agent_chat(cfg):
|
|
|
15964
16054
|
result += _pipe_hidden_failure_note(_hidden, ps=_ps)
|
|
15965
16055
|
_emit({"type": "step", "text":
|
|
15966
16056
|
f"Command failed behind a pipe → {_trim_words(_hidden)}"})
|
|
16057
|
+
if _ps:
|
|
16058
|
+
result += _ps_search_tool_hint(out)
|
|
15967
16059
|
if rc != 0 and _use_shell and (_PS_QUOTE_ERR_RE if _ps else _SHELL_QUOTE_ERR_RE).search(out or ""):
|
|
15968
16060
|
result += _PS_HEREDOC_HINT if _ps else _HEREDOC_HINT
|
|
15969
16061
|
# Say where the shell now is, so a next `cd sub` is not done twice (#362).
|
package/gu-cli.mjs
CHANGED
|
@@ -1823,7 +1823,17 @@ if (args[0] === "deps") {
|
|
|
1823
1823
|
// The agent's Python packages (smolagents + openai), installed in a plain process — what the installer and the
|
|
1824
1824
|
// first start run, and what to run by hand when either said it could not (GH #308).
|
|
1825
1825
|
const md = await import("./model-doctor.mjs");
|
|
1826
|
-
|
|
1826
|
+
// Running `gu-cli deps` agrees to the agent framework; installing Python itself (Windows, GH #363) is asked on its own.
|
|
1827
|
+
const { askLine } = await import("./agent-deps-preflight.mjs");
|
|
1828
|
+
const out = await md.ensureAgentDeps({
|
|
1829
|
+
log: (l) => console.log(l),
|
|
1830
|
+
confirm: async (q, defYes = true) => {
|
|
1831
|
+
const typed = String(await askLine(" " + q + (defYes ? " [Y/n]: " : " [y/N]: "))).trim().toLowerCase();
|
|
1832
|
+
return typed === "" ? defYes : /^y(es)?$/.test(typed);
|
|
1833
|
+
},
|
|
1834
|
+
modelHost: false,
|
|
1835
|
+
agreed: ["agentLibs"],
|
|
1836
|
+
});
|
|
1827
1837
|
const ok = out.agentLibs === "installed" || out.agentLibs === "present";
|
|
1828
1838
|
console.log(ok ? "\n ✓ the agent framework is ready." : "\n ✗ the agent framework is not installed — see the message above and " + md.PIP_LOG_FILE);
|
|
1829
1839
|
process.exit(ok ? 0 : 1);
|
package/gu-repl.mjs
CHANGED
|
@@ -73,6 +73,8 @@ import {
|
|
|
73
73
|
tokenRowWithPrice,
|
|
74
74
|
visibleLen,
|
|
75
75
|
createProbeReplyReader,
|
|
76
|
+
createMouseReportReader,
|
|
77
|
+
stripLeakedMouseReports,
|
|
76
78
|
agentPickerItems,
|
|
77
79
|
agentsIndicator,
|
|
78
80
|
classifyCliArg,
|
|
@@ -709,7 +711,17 @@ if (REPL_LOG) {
|
|
|
709
711
|
const md0 = await import("./model-doctor.mjs");
|
|
710
712
|
await pre.agentDepsPreflight({
|
|
711
713
|
hasLibs: () => md0.hasAgentLibs(),
|
|
712
|
-
|
|
714
|
+
// The "Install it now?" above agreed to the agent framework ONLY (agreed: agentLibs). Anything else this install
|
|
715
|
+
// would do — installing Python itself on Windows (GH #363) — is a new decision and gets its own question.
|
|
716
|
+
install: () => md0.ensureAgentDeps({
|
|
717
|
+
log: (l) => console.log(l),
|
|
718
|
+
confirm: async (q, defYes = true) => {
|
|
719
|
+
const typed = String(await pre.askLine(" " + q + (defYes ? " [Y/n]: " : " [y/N]: "))).trim().toLowerCase();
|
|
720
|
+
return typed === "" ? defYes : /^y(es)?$/.test(typed);
|
|
721
|
+
},
|
|
722
|
+
modelHost: false,
|
|
723
|
+
agreed: ["agentLibs"],
|
|
724
|
+
}),
|
|
713
725
|
markDeclined: pre.markDeclinedOnDisk,
|
|
714
726
|
});
|
|
715
727
|
}
|
|
@@ -1304,7 +1316,30 @@ const PASTE_MODE_OFF = "\x1b[?2004l";
|
|
|
1304
1316
|
// short paste goes in literally, a long one becomes "[Pasted text #N +M lines]" with the real
|
|
1305
1317
|
// text kept in pasteStore and expanded when the turn is sent.
|
|
1306
1318
|
let pasteBuffer = null;
|
|
1307
|
-
|
|
1319
|
+
// Mouse reports in every shape node can deliver them (GH #365) — see createMouseReportReader.
|
|
1320
|
+
const mouseReader = createMouseReportReader();
|
|
1321
|
+
let _mouseTimer = null;
|
|
1322
|
+
let _mouseReplaying = false; // replayed keys skip the mouse reader, and only the mouse reader
|
|
1323
|
+
function mouseClearTimer() {
|
|
1324
|
+
if (_mouseTimer) {
|
|
1325
|
+
clearTimeout(_mouseTimer);
|
|
1326
|
+
_mouseTimer = null;
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
/** Not a report after all: give the keys back to the rest of the input path, in order. */
|
|
1330
|
+
function mouseReplay(keys) {
|
|
1331
|
+
_mouseReplaying = true;
|
|
1332
|
+
try {
|
|
1333
|
+
for (const [s, k] of keys) rl._ttyWrite(s, k);
|
|
1334
|
+
} finally {
|
|
1335
|
+
_mouseReplaying = false;
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
/** Timer fired: what is still held (a lone Esc, a "[" with nothing after it) is a keystroke. */
|
|
1339
|
+
function mouseFlush() {
|
|
1340
|
+
_mouseTimer = null;
|
|
1341
|
+
mouseReplay(mouseReader.flush());
|
|
1342
|
+
}
|
|
1308
1343
|
const _origTtyWrite = rl._ttyWrite.bind(rl);
|
|
1309
1344
|
rl._ttyWrite = (str, key) => {
|
|
1310
1345
|
// A LIST PICKER OWNS EVERY KEY WHILE IT IS UP, and this has to happen HERE rather than in the
|
|
@@ -1384,20 +1419,26 @@ rl._ttyWrite = (str, key) => {
|
|
|
1384
1419
|
// readline parses an SGR mouse report only as far as its "\x1b[<" prefix, then hands the
|
|
1385
1420
|
// REST through as individual characters — which is how "0;62;21M" ended up typed into the
|
|
1386
1421
|
// line. Buffer from the prefix to the M/m terminator so nothing leaks, then move the caret.
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
if (
|
|
1392
|
-
|
|
1393
|
-
if (
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1422
|
+
//
|
|
1423
|
+
// GH #365: by EVERY shape, not only node's "[<" key — an Esc that timed out (a stalled event
|
|
1424
|
+
// loop, e.g. the repaint after restoring a window on Windows) sends "[<35;114;22M" through as
|
|
1425
|
+
// plain characters, which is how hovering typed junk into the line. See createMouseReportReader.
|
|
1426
|
+
if (!_mouseReplaying) {
|
|
1427
|
+
const verdict = mouseReader.feed(str, key, { plainLeads: _clickEnabled || _mouseEnabled });
|
|
1428
|
+
if (verdict.action === "hold") {
|
|
1429
|
+
if (!_mouseTimer) _mouseTimer = setTimeout(mouseFlush, 300);
|
|
1430
|
+
return;
|
|
1431
|
+
}
|
|
1432
|
+
if (verdict.action === "consume") {
|
|
1433
|
+
mouseClearTimer();
|
|
1434
|
+
applyMouseReport(verdict.report);
|
|
1435
|
+
return;
|
|
1436
|
+
}
|
|
1437
|
+
if (verdict.action === "replay") {
|
|
1438
|
+
if (!verdict.hold) mouseClearTimer();
|
|
1439
|
+
mouseReplay(verdict.keys);
|
|
1440
|
+
return;
|
|
1399
1441
|
}
|
|
1400
|
-
return;
|
|
1401
1442
|
}
|
|
1402
1443
|
// The terminal's answer to our \x1b[6n probe (ESC [ row ; col R) — the caret's ABSOLUTE row,
|
|
1403
1444
|
// which is what mouse reports are measured against. It is INPUT: any byte of it we fail to
|
|
@@ -2119,6 +2160,8 @@ let slashSel = -1; // highlighted row in the current filtered list (-1 = none)
|
|
|
2119
2160
|
let slashNames = []; // command names in the current filtered list (for Enter → run)
|
|
2120
2161
|
let slashPrefix = ""; // the "/…" text the user typed (restored when ↑/↓ recall history)
|
|
2121
2162
|
rl.on("line", (l) => {
|
|
2163
|
+
// GH #365: a mouse report that got past every reader is junk, not part of the question.
|
|
2164
|
+
l = stripLeakedMouseReports(l);
|
|
2122
2165
|
// Enter ends any drag: if a release was lost (focus left the window mid-drag) the highlight
|
|
2123
2166
|
// would otherwise stay painted over text nobody is selecting — see endDrag.
|
|
2124
2167
|
endDrag();
|
|
@@ -14425,6 +14468,9 @@ async function main() {
|
|
|
14425
14468
|
// The yes two lines up IS the answer — do not ask the same thing again (#333).
|
|
14426
14469
|
agreed: ["agentLibs"],
|
|
14427
14470
|
});
|
|
14471
|
+
if (depsOut?.python3 === "missing") {
|
|
14472
|
+
console.log(yellow(" ⚠ Without Python the agent cannot answer. Setup goes on, but install Python 3 and run `gu doctor` before your first question.\n"));
|
|
14473
|
+
}
|
|
14428
14474
|
// The worker reads worker.env ONCE at startup, so a daemon that is already
|
|
14429
14475
|
// running still has the OLD python — the packages would look "not installed"
|
|
14430
14476
|
// for the rest of its life. Restart it here rather than leaving the user to
|
package/model-doctor.mjs
CHANGED
|
@@ -1354,11 +1354,24 @@ export async function ensureClaudeSdk({ log, confirm }) {
|
|
|
1354
1354
|
* (smolagents + openai) — required to run the agent?" — the same question twice in a row on
|
|
1355
1355
|
* every first install. A yes that was just given is not asked for again.
|
|
1356
1356
|
*/
|
|
1357
|
-
export async function ensureAgentDeps({ log, confirm, modelHost = canHostMlxModels(), agreed = [] }) {
|
|
1357
|
+
export async function ensureAgentDeps({ log, confirm, modelHost = canHostMlxModels(), agreed = [], os = platform(), installPython = null }) {
|
|
1358
1358
|
const out = {};
|
|
1359
1359
|
log("");
|
|
1360
1360
|
log(" Checking the agent's dependencies…");
|
|
1361
|
-
// python3 — the whole agent runs on it
|
|
1361
|
+
// python3 — the whole agent runs on it. On Windows gu offers to install it for this user (GH #363); elsewhere it can't.
|
|
1362
|
+
if (!(await pyHasModule("sys")) && os === "win32") {
|
|
1363
|
+
const yes = await confirm(
|
|
1364
|
+
"Python 3 is not installed, and the agent runs on it. Install Python 3.12 for your user only? (about 25 MB from python.org; no administrator needed)",
|
|
1365
|
+
true
|
|
1366
|
+
);
|
|
1367
|
+
if (yes) {
|
|
1368
|
+
log(" Installing Python 3.12 …");
|
|
1369
|
+
const install = installPython ?? (await import("./python-windows.mjs")).installPythonWindows;
|
|
1370
|
+
const r = await install({});
|
|
1371
|
+
if (r.ok) log(" ✓ Python 3.12 installed");
|
|
1372
|
+
else log(` ⚠ could not install Python: ${r.error ?? "unknown error"}`);
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1362
1375
|
if (!(await pyHasModule("sys"))) {
|
|
1363
1376
|
log(` ⚠ ${python3Bin()} not found. Install Python 3 — ${pythonInstallHint()} —`);
|
|
1364
1377
|
log(" then run `gu-cli doctor` again.");
|
package/mongo-windows.mjs
CHANGED
|
@@ -17,6 +17,7 @@ import { mkdir, rename, writeFile, rm, stat } from "node:fs/promises";
|
|
|
17
17
|
import { homedir } from "node:os";
|
|
18
18
|
import { join, dirname } from "node:path";
|
|
19
19
|
import { inflateRawSync } from "node:zlib";
|
|
20
|
+
import { execFile } from "node:child_process";
|
|
20
21
|
|
|
21
22
|
// Bump on purpose, like MONGO_WINGET_VERSION: 9.0.4 is what fastdl serves today (winget has 9.0.2).
|
|
22
23
|
export const MONGO_WIN_VERSION = "9.0.4";
|
|
@@ -244,3 +245,65 @@ export async function startPortableMongod({ port = 27017, paths = mongoWinPaths(
|
|
|
244
245
|
taskName: "gu-local-mongod",
|
|
245
246
|
});
|
|
246
247
|
}
|
|
248
|
+
|
|
249
|
+
// ------------------------------------------------------------------- the Visual C++ runtime (GH #363)
|
|
250
|
+
//
|
|
251
|
+
// mongod.exe needs Microsoft's Visual C++ runtime (vcruntime140.dll, msvcp140.dll). A brand-new Windows 11
|
|
252
|
+
// has none, so mongod stops at once with 0xC0000135 (STATUS_DLL_NOT_FOUND) and — started detached — nobody
|
|
253
|
+
// sees why: gu only said "not answering yet" and the user was sent round in a circle.
|
|
254
|
+
|
|
255
|
+
export const VC_REDIST_URL = "https://aka.ms/vs/17/release/vc_redist.x64.exe";
|
|
256
|
+
|
|
257
|
+
/** The exit code Windows gives a program whose DLL is missing: 0xC0000135, as unsigned or signed. */
|
|
258
|
+
export function isDllNotFoundExit(code) {
|
|
259
|
+
return code === 0xC0000135 || code === -1073741515;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Can this mongod.exe start at all? → { ok, code }. Runs `mongod --version` (it prints and exits). */
|
|
263
|
+
export function mongodRuns(paths = mongoWinPaths(), { execFn = execFile, timeoutMs = 20000 } = {}) {
|
|
264
|
+
return new Promise((resolve) => {
|
|
265
|
+
try {
|
|
266
|
+
execFn(paths.exe, ["--version"], { timeout: timeoutMs, windowsHide: true }, (err) => {
|
|
267
|
+
if (!err) return resolve({ ok: true, code: 0 });
|
|
268
|
+
const code = typeof err.code === "number" ? err.code : null;
|
|
269
|
+
// ENOENT / killed by the timeout carry a string or signal, not an exit code: not "a DLL is missing".
|
|
270
|
+
resolve({ ok: false, code });
|
|
271
|
+
});
|
|
272
|
+
} catch {
|
|
273
|
+
resolve({ ok: false, code: null });
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Exit codes of vc_redist.x64.exe that mean "it is there now": done, reboot wanted, a newer one already installed. */
|
|
279
|
+
export function vcRedistInstalled(code) {
|
|
280
|
+
return code === 0 || code === 3010 || code === 1638;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Download Microsoft's runtime installer and run it quietly. Installing it needs administrator rights, so it is
|
|
285
|
+
* started with `-Verb RunAs` (one Windows permission prompt); a non-elevated child_process could not ask.
|
|
286
|
+
* → { ok, code, error? }. `download` and `run` are injectable so tests never touch the net or the machine.
|
|
287
|
+
*/
|
|
288
|
+
export async function installVcRuntime({ dir = join(homedir(), ".gonext"), fetchFn = fetch, run = null } = {}) {
|
|
289
|
+
const exe = join(dir, "vc_redist.x64.exe");
|
|
290
|
+
try {
|
|
291
|
+
await mkdir(dir, { recursive: true });
|
|
292
|
+
const res = await fetchFn(VC_REDIST_URL, { redirect: "follow" });
|
|
293
|
+
if (!res.ok) return { ok: false, code: null, error: `download failed (HTTP ${res.status})` };
|
|
294
|
+
await writeFile(exe, Buffer.from(await res.arrayBuffer()));
|
|
295
|
+
} catch (e) {
|
|
296
|
+
return { ok: false, code: null, error: `download failed: ${e?.message ?? e}` };
|
|
297
|
+
}
|
|
298
|
+
const runner = run ?? ((file) => new Promise((resolve) => {
|
|
299
|
+
const script = `$p = Start-Process -FilePath '${file.replace(/'/g, "''")}' -ArgumentList '/install','/quiet','/norestart' -Verb RunAs -Wait -PassThru; exit $p.ExitCode`;
|
|
300
|
+
execFile("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { windowsHide: true, timeout: 600000 }, (err, _o, stderr) => {
|
|
301
|
+
resolve({ code: err ? (typeof err.code === "number" ? err.code : null) : 0, stderr: String(stderr ?? "") });
|
|
302
|
+
});
|
|
303
|
+
}));
|
|
304
|
+
const r = await runner(exe);
|
|
305
|
+
await rm(exe, { force: true }).catch(() => {});
|
|
306
|
+
if (vcRedistInstalled(r.code)) return { ok: true, code: r.code };
|
|
307
|
+
const declined = /canceled by the user|operation was canceled/i.test(r.stderr ?? "");
|
|
308
|
+
return { ok: false, code: r.code, error: declined ? "the administrator permission prompt was declined" : `the installer stopped with code ${r.code}` };
|
|
309
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tiens.nguyen/gu-cli",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "gu: the coding agent in your terminal. It reads your repo, makes the change and runs the tests, with your own models (Ollama
|
|
3
|
+
"version": "1.0.894",
|
|
4
|
+
"description": "gu: the coding agent in your terminal. It reads your repo, makes the change and runs the tests, with your own models (Ollama or any OpenAI-compatible provider). Free installs run entirely on your machine. macOS, Linux and Windows.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
7
7
|
"coding-agent",
|
|
@@ -9,7 +9,6 @@
|
|
|
9
9
|
"terminal",
|
|
10
10
|
"llm",
|
|
11
11
|
"ollama",
|
|
12
|
-
"mlx",
|
|
13
12
|
"openai",
|
|
14
13
|
"agent",
|
|
15
14
|
"mcp",
|
|
@@ -76,6 +75,7 @@
|
|
|
76
75
|
"workspace-revert.mjs",
|
|
77
76
|
"mongo-native.mjs",
|
|
78
77
|
"mongo-windows.mjs",
|
|
78
|
+
"python-windows.mjs",
|
|
79
79
|
"terminal-hover.mjs",
|
|
80
80
|
"terminal-layout.mjs",
|
|
81
81
|
"terminal-viewport.mjs",
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PYTHON ON A NEW WINDOWS 11 (GH #363).
|
|
3
|
+
*
|
|
4
|
+
* A brand-new Windows 11 has no Python, and gu's agent runs on it. gu used to say "Install Python 3 — winget …"
|
|
5
|
+
* and then carry on through a long database setup that ended in a broken agent. Like the installer does for
|
|
6
|
+
* Node, gu now offers to do it: the official python.org installer, for THIS USER only (no administrator, no
|
|
7
|
+
* system-wide change), after checking that the file is signed by the Python Software Foundation.
|
|
8
|
+
*
|
|
9
|
+
* Pure parts (URL, folders, the PowerShell script) are separate; the net and the machine are injected so tests
|
|
10
|
+
* never download or install anything.
|
|
11
|
+
*/
|
|
12
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { execFile } from "node:child_process";
|
|
16
|
+
|
|
17
|
+
// Bump on purpose. 3.12 has prebuilt wheels for everything gu installs (see PYTHON_WHEELS_MAX).
|
|
18
|
+
export const PYTHON_WIN_VERSION = "3.12.8";
|
|
19
|
+
|
|
20
|
+
export function pythonWinInstallerUrl(version = PYTHON_WIN_VERSION) {
|
|
21
|
+
return `https://www.python.org/ftp/python/${version}/python-${version}-amd64.exe`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Where a per-user install lands: %LOCALAPPDATA%\Programs\Python\Python312, its Scripts, and the `py` launcher. */
|
|
25
|
+
export function pythonWinFolders(version = PYTHON_WIN_VERSION, localAppData = process.env.LOCALAPPDATA || join(homedir(), "AppData", "Local")) {
|
|
26
|
+
const [maj, min] = version.split(".");
|
|
27
|
+
const root = join(localAppData, "Programs", "Python", `Python${maj}${min}`);
|
|
28
|
+
return [root, join(root, "Scripts"), join(localAppData, "Programs", "Python", "Launcher")];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Put the new folders on THIS process's PATH: the installer only edits the saved PATH, which only new terminals read. */
|
|
32
|
+
export function withPythonOnPath(pathValue, folders) {
|
|
33
|
+
const have = String(pathValue ?? "").split(";").map((p) => p.trim().toLowerCase());
|
|
34
|
+
const add = folders.filter((f) => !have.includes(f.toLowerCase()));
|
|
35
|
+
return add.length ? add.join(";") + ";" + String(pathValue ?? "") : String(pathValue ?? "");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The PowerShell that checks the signature, then installs quietly for the current user. Exit 90 = not signed by the PSF. */
|
|
39
|
+
export function pythonInstallScript(file) {
|
|
40
|
+
const f = file.replace(/'/g, "''");
|
|
41
|
+
return [
|
|
42
|
+
`$f = '${f}'`,
|
|
43
|
+
`$s = Get-AuthenticodeSignature -FilePath $f`,
|
|
44
|
+
`if ($s.Status -ne 'Valid' -or "$($s.SignerCertificate.Subject)" -notmatch 'Python Software Foundation') { exit 90 }`,
|
|
45
|
+
`$p = Start-Process -FilePath $f -ArgumentList '/quiet','InstallAllUsers=0','PrependPath=1','Include_launcher=1','Include_test=0' -Wait -PassThru`,
|
|
46
|
+
`exit $p.ExitCode`,
|
|
47
|
+
].join("; ");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The environment for the Python installer: the REAL profile. A test (or anyone) may point USERPROFILE/HOME at a scratch
|
|
52
|
+
* folder; the installer then fails with 0x80070003 ("path not found"). LOCALAPPDATA still names the real profile
|
|
53
|
+
* (<profile>\\AppData\\Local), so the profile is derived from it. Pure.
|
|
54
|
+
*/
|
|
55
|
+
export function pythonInstallerEnv(env = process.env) {
|
|
56
|
+
const out = { ...env };
|
|
57
|
+
const m = /^(.*)[\\/]AppData[\\/]Local[\\/]?$/i.exec(String(env.LOCALAPPDATA ?? ""));
|
|
58
|
+
if (m && m[1]) {
|
|
59
|
+
out.USERPROFILE = m[1];
|
|
60
|
+
out.HOME = m[1];
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Download, verify, install. → { ok, folders?, error? }. `fetchFn` and `run` (file → { code }) are injectable.
|
|
67
|
+
* On success the process PATH is updated so the very next `py -3` in this run finds it.
|
|
68
|
+
*/
|
|
69
|
+
export async function installPythonWindows({ dir = join(homedir(), ".gonext"), version = PYTHON_WIN_VERSION, fetchFn = fetch, run = null, env = process.env } = {}) {
|
|
70
|
+
const file = join(dir, `python-${version}-amd64.exe`);
|
|
71
|
+
try {
|
|
72
|
+
await mkdir(dir, { recursive: true });
|
|
73
|
+
const res = await fetchFn(pythonWinInstallerUrl(version), { redirect: "follow" });
|
|
74
|
+
if (!res.ok) return { ok: false, error: `download failed (HTTP ${res.status})` };
|
|
75
|
+
await writeFile(file, Buffer.from(await res.arrayBuffer()));
|
|
76
|
+
} catch (e) {
|
|
77
|
+
return { ok: false, error: `download failed: ${e?.message ?? e}` };
|
|
78
|
+
}
|
|
79
|
+
const runner = run ?? ((f) => new Promise((resolve) => {
|
|
80
|
+
execFile("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", pythonInstallScript(f)], { windowsHide: true, timeout: 900000, env: pythonInstallerEnv(env) }, (err) => {
|
|
81
|
+
resolve({ code: err ? (typeof err.code === "number" ? err.code : null) : 0 });
|
|
82
|
+
});
|
|
83
|
+
}));
|
|
84
|
+
const r = await runner(file);
|
|
85
|
+
await rm(file, { force: true }).catch(() => {});
|
|
86
|
+
if (r.code === 90) return { ok: false, error: "the downloaded file is not signed by the Python Software Foundation, so it was NOT installed" };
|
|
87
|
+
if (r.code !== 0) return { ok: false, error: `the Python installer stopped with code ${r.code}` };
|
|
88
|
+
const folders = pythonWinFolders(version, env.LOCALAPPDATA || undefined);
|
|
89
|
+
env.Path !== undefined ? (env.Path = withPythonOnPath(env.Path, folders)) : (env.PATH = withPythonOnPath(env.PATH, folders));
|
|
90
|
+
return { ok: true, folders };
|
|
91
|
+
}
|
package/terminal-layout.mjs
CHANGED
|
@@ -451,9 +451,16 @@ export function promptBreakdownRows(b) {
|
|
|
451
451
|
export function createMouseKeyWindow({ maxRun = 24 } = {}) {
|
|
452
452
|
let run = 0;
|
|
453
453
|
let current = false;
|
|
454
|
+
let prev = ""; // the previous key's text, for the split shape below
|
|
454
455
|
return {
|
|
455
456
|
track(str, key) {
|
|
456
|
-
|
|
457
|
+
const text = typeof str === "string" ? str : "";
|
|
458
|
+
// The joined shape ("[<" as one key) — or the SPLIT one (GH #365): an Esc node timed out
|
|
459
|
+
// on, then "[" and "<" as ordinary characters. The "[" has already gone by as a key by
|
|
460
|
+
// then; the run starts at the "<".
|
|
461
|
+
const split = text === "<" && prev === "[";
|
|
462
|
+
prev = key?.code === "[<" ? "" : text;
|
|
463
|
+
if (key?.code === "[<" || (split && !run)) {
|
|
457
464
|
run = 1;
|
|
458
465
|
return (current = true);
|
|
459
466
|
}
|
|
@@ -466,6 +473,100 @@ export function createMouseKeyWindow({ maxRun = 24 } = {}) {
|
|
|
466
473
|
};
|
|
467
474
|
}
|
|
468
475
|
|
|
476
|
+
/**
|
|
477
|
+
* KEEP EVERY SHAPE OF A MOUSE REPORT OUT OF THE LINE (GH #365).
|
|
478
|
+
*
|
|
479
|
+
* The input filter used to recognise a report only by node's key code "[<" — the shape node
|
|
480
|
+
* produces when `ESC [ <` arrives together. When the event loop stalls for longer than
|
|
481
|
+
* readline's escape timeout (500ms) with an ESC at the end of a read, node gives the ESC up as a
|
|
482
|
+
* lone Esc key and the rest comes through as ORDINARY characters: "[", "<", "35;114;22M". Every
|
|
483
|
+
* one of those was typed into the line. Reported on Windows 11 after minimising and restoring
|
|
484
|
+
* the window (a full repaint, and Windows console writes block), then more with every hover,
|
|
485
|
+
* because the prompt reports mouse motion.
|
|
486
|
+
*
|
|
487
|
+
* Shapes, all consumed whole:
|
|
488
|
+
* key "[<", then "35;114;22M" ← the usual one
|
|
489
|
+
* Esc, "[", "<", "35;114;22M" ← the ESC timed out
|
|
490
|
+
* "[", "<", "35;114;22M" ← the Esc went elsewhere (plainLeads only)
|
|
491
|
+
* "^", "[", "[", "<", … ← ESC spelled in caret notation (plainLeads only)
|
|
492
|
+
*
|
|
493
|
+
* Like createProbeReplyReader it holds anything that still fits the grammar and REPLAYS it
|
|
494
|
+
* verbatim the moment something does not, so a keystroke is delayed, never lost. A plain "[" or
|
|
495
|
+
* "^" is only held while the terminal is reporting the mouse (`plainLeads`), so typing them is
|
|
496
|
+
* not slowed down otherwise.
|
|
497
|
+
*/
|
|
498
|
+
const MOUSE_LEADS = ["\x1b[<", "^[[<", "[<"];
|
|
499
|
+
const MOUSE_BODY_DONE = /^(\d+;\d+;\d+[Mm])$/;
|
|
500
|
+
const MOUSE_BODY_PART = /^\d*(?:;\d*){0,2}$/;
|
|
501
|
+
function mouseTextVerdict(text, plainLeads) {
|
|
502
|
+
for (const lead of MOUSE_LEADS) {
|
|
503
|
+
if (!plainLeads && lead !== "\x1b[<") continue;
|
|
504
|
+
if (lead.startsWith(text)) return { kind: "partial" };
|
|
505
|
+
if (!text.startsWith(lead)) continue;
|
|
506
|
+
const body = text.slice(lead.length);
|
|
507
|
+
if (MOUSE_BODY_DONE.test(body)) return { kind: "done", report: body };
|
|
508
|
+
if (body.length <= 20 && MOUSE_BODY_PART.test(body)) return { kind: "partial" };
|
|
509
|
+
}
|
|
510
|
+
return { kind: "bad" };
|
|
511
|
+
}
|
|
512
|
+
export function createMouseReportReader() {
|
|
513
|
+
let held = null; // [str, key] pairs
|
|
514
|
+
const textOf = (str, key) =>
|
|
515
|
+
key?.code === "[<" ? "\x1b[<" : key?.name === "escape" ? "\x1b" : typeof str === "string" ? str : "";
|
|
516
|
+
const startsOne = (str, key, plainLeads) =>
|
|
517
|
+
key?.code === "[<" || key?.name === "escape" || (plainLeads && (str === "[" || str === "^"));
|
|
518
|
+
return {
|
|
519
|
+
holding: () => held !== null,
|
|
520
|
+
/**
|
|
521
|
+
* Feed one keypress. `plainLeads`: the terminal is reporting the mouse right now.
|
|
522
|
+
* { action: "pass" } — not ours; carry on
|
|
523
|
+
* { action: "hold" } — may be a report; arm the flush timer
|
|
524
|
+
* { action: "consume", report } — a whole report, e.g. "35;114;22M"
|
|
525
|
+
* { action: "replay", keys, hold? } — not a report: replay `keys`; when `hold` is
|
|
526
|
+
* set, the last key began a new candidate and
|
|
527
|
+
* is still held
|
|
528
|
+
*/
|
|
529
|
+
feed(str, key, { plainLeads = false } = {}) {
|
|
530
|
+
if (!held) {
|
|
531
|
+
if (!startsOne(str, key, plainLeads)) return { action: "pass" };
|
|
532
|
+
held = [[str, key]];
|
|
533
|
+
const v = mouseTextVerdict(textOf(str, key), plainLeads);
|
|
534
|
+
if (v.kind === "partial") return { action: "hold" };
|
|
535
|
+
held = null;
|
|
536
|
+
return { action: "pass" };
|
|
537
|
+
}
|
|
538
|
+
held.push([str, key]);
|
|
539
|
+
const text = held.map(([s, k]) => textOf(s, k)).join("");
|
|
540
|
+
const v = mouseTextVerdict(text, plainLeads);
|
|
541
|
+
if (v.kind === "done") {
|
|
542
|
+
held = null;
|
|
543
|
+
return { action: "consume", report: v.report };
|
|
544
|
+
}
|
|
545
|
+
if (v.kind === "partial") return { action: "hold" };
|
|
546
|
+
// Not a report. If the key that broke it could START one ("[" after "[", an Esc), keep it.
|
|
547
|
+
const last = held.pop();
|
|
548
|
+
const keys = held;
|
|
549
|
+
held = null;
|
|
550
|
+
if (startsOne(last[0], last[1], plainLeads) && mouseTextVerdict(textOf(last[0], last[1]), plainLeads).kind === "partial") {
|
|
551
|
+
held = [last];
|
|
552
|
+
return { action: "replay", keys, hold: true };
|
|
553
|
+
}
|
|
554
|
+
return { action: "replay", keys: [...keys, last] };
|
|
555
|
+
},
|
|
556
|
+
/** Timer fired: hand back whatever is still held. */
|
|
557
|
+
flush() {
|
|
558
|
+
const keys = held ?? [];
|
|
559
|
+
held = null;
|
|
560
|
+
return keys;
|
|
561
|
+
},
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** Last line of defence at submit: a report that got past every reader is not a question. */
|
|
566
|
+
export function stripLeakedMouseReports(line) {
|
|
567
|
+
return String(line).replace(/(?:\x1b|\^\[)?\[<\d+;\d+;\d+[Mm]/g, "");
|
|
568
|
+
}
|
|
569
|
+
|
|
469
570
|
/**
|
|
470
571
|
* A failed turn's message, as something a person can act on.
|
|
471
572
|
*
|