@tiens.nguyen/gu-cli 1.0.895 → 1.0.897
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 +1 -1
- package/agent-model-command.mjs +15 -0
- package/cli-token.mjs +8 -3
- package/coding-provider-setup.mjs +1 -1
- package/daemon-control.mjs +2 -1
- package/doctor-check.mjs +63 -1
- package/gonext_agent_chat.py +16 -7
- package/gu-repl.mjs +209 -62
- package/package.json +1 -1
- package/platform-tools.mjs +10 -3
- package/terminal-layout.mjs +98 -8
package/README.md
CHANGED
|
@@ -188,7 +188,7 @@ gu fix-path | fix-powershell repair PATH / remove PowerShell's `gu` alias
|
|
|
188
188
|
| --- | --- | --- |
|
|
189
189
|
| **Free trial** | 30 days, from the first run | nothing: the token is created for you |
|
|
190
190
|
| **Free plan** | 3 more months on that computer | a free account at [gucli.com](https://gucli.com) |
|
|
191
|
-
| **Pro** | **$4 a month** or **$40 a year** (+ VAT where it applies) | hosted models (no model server to run),
|
|
191
|
+
| **Pro** | **$4 a month** or **$40 a year** (+ VAT where it applies) | hosted models (no model server to run), any number of computers, provider budgets |
|
|
192
192
|
|
|
193
193
|
On Pro, the hosted models and the hosted API run on gu's servers, so the prompts and code you
|
|
194
194
|
send through them go there. A free install keeps everything on your computer.
|
package/agent-model-command.mjs
CHANGED
|
@@ -119,6 +119,11 @@ export function agentModelRows(models, current) {
|
|
|
119
119
|
* model is always named, because that is the one fact still true when the network is not.
|
|
120
120
|
*/
|
|
121
121
|
export function describeTagsFailure({ url, current, error, proxied = false } = {}) {
|
|
122
|
+
// NOTHING CONFIGURED IS NOT A FAILURE (GH #397). There was no list to fetch, so "couldn't fetch
|
|
123
|
+
// the model list from (no agent model URL configured)", its echo and a /proxy hint were three
|
|
124
|
+
// ways of saying one thing — and the proxy cannot help with a URL that does not exist. Same
|
|
125
|
+
// sentence /model uses for the same state.
|
|
126
|
+
if (!url && !current) return ["No agent model configured."];
|
|
122
127
|
const lines = [
|
|
123
128
|
`couldn't fetch the model list from ${url || "(no agent model URL configured)"}`,
|
|
124
129
|
];
|
|
@@ -355,6 +360,16 @@ export function agentRecoveryOptions({ keyProblem = false, host = "" } = {}) {
|
|
|
355
360
|
];
|
|
356
361
|
}
|
|
357
362
|
|
|
363
|
+
/**
|
|
364
|
+
* Which recovery row the cursor starts on (GH #397). A key problem → the key row. NOTHING SET UP →
|
|
365
|
+
* "add a provider": the only way forward there, and what /model already highlights for its own
|
|
366
|
+
* "No coding model configured." Otherwise "leave it", so Enter on an unreachable box changes nothing.
|
|
367
|
+
*/
|
|
368
|
+
export function agentRecoveryDefault(options, { keyProblem = false, configured = true } = {}) {
|
|
369
|
+
const want = keyProblem ? "key" : configured ? "leave" : "provider";
|
|
370
|
+
return Math.max(0, options.findIndex((o) => o.action === want));
|
|
371
|
+
}
|
|
372
|
+
|
|
358
373
|
// ---------------------------------------------------------------------------------------------
|
|
359
374
|
// #44 — WHAT TO CHANGE comes first.
|
|
360
375
|
// ---------------------------------------------------------------------------------------------
|
package/cli-token.mjs
CHANGED
|
@@ -622,7 +622,8 @@ export function barNotice({ token, publicKeyB64, mode, now = new Date(), attempt
|
|
|
622
622
|
// SHORT AND CALM (#292: "make it simpler … seems so serious"): the fact, then the one thing to
|
|
623
623
|
// do. `urgent` is what the bar colours by — only these two need the user to act.
|
|
624
624
|
if (ended) return { kind: "plan-ended", urgent: true, text: `Pro ended ${shortDate(p.paidUntil)} · renew at ${pricingUrl}` };
|
|
625
|
-
|
|
625
|
+
// Pro has no computer limit (GH #371): an over-limit line is for the other plans only.
|
|
626
|
+
if (p.plan !== "pro" && Number.isInteger(p.activeDevices) && p.activeDevices > p.deviceLimit) {
|
|
626
627
|
return {
|
|
627
628
|
kind: "over-limit",
|
|
628
629
|
urgent: true,
|
|
@@ -728,7 +729,10 @@ export function tokenSummary(payload, now = new Date()) {
|
|
|
728
729
|
}
|
|
729
730
|
if (Number.isInteger(payload.activeDevices)) {
|
|
730
731
|
const n = payload.activeDevices;
|
|
731
|
-
|
|
732
|
+
const paidPro = payload.plan === "pro" && payload.paidUntil && Date.parse(payload.paidUntil) > now.getTime();
|
|
733
|
+
lines.push(paidPro
|
|
734
|
+
? `Used on ${computers(n)} in the last 30 days — Pro has no computer limit.`
|
|
735
|
+
: `Used on ${computers(n)} in the last 30 days — the ${planName(payload.plan)} allows ${payload.deviceLimit}.`);
|
|
732
736
|
}
|
|
733
737
|
lines.push(`Issued ${shortDate(payload.issuedAt)}.`);
|
|
734
738
|
return lines;
|
|
@@ -827,7 +831,8 @@ export function enforcementDecision({ token, publicKeyB64, now = new Date(), sit
|
|
|
827
831
|
);
|
|
828
832
|
}
|
|
829
833
|
}
|
|
830
|
-
|
|
834
|
+
// PRO HAS NO COMPUTER LIMIT (GH #371) — not even with a token from a server that still sends a count.
|
|
835
|
+
if (!paid && Number.isInteger(p.activeDevices) && p.activeDevices > p.deviceLimit) {
|
|
831
836
|
return {
|
|
832
837
|
enforced: true,
|
|
833
838
|
allowed: false,
|
|
@@ -729,7 +729,7 @@ const NOT_A_CHAT_MODEL = [
|
|
|
729
729
|
/moderation/i,
|
|
730
730
|
/\btts\b|-tts|text-to-speech/i,
|
|
731
731
|
/whisper|transcribe|audio|realtime|voice/i,
|
|
732
|
-
/image|vision-preview|dall-?e|sora/i,
|
|
732
|
+
/image|vision-preview|dall-?e|sora|video/i,
|
|
733
733
|
/search(-preview|-api)?$|-search-/i,
|
|
734
734
|
/rerank|guard|classifier/i,
|
|
735
735
|
// OpenAI's original completion endpoints — they take a prompt, not messages.
|
package/daemon-control.mjs
CHANGED
|
@@ -18,7 +18,8 @@ import {
|
|
|
18
18
|
spawnSurviving,
|
|
19
19
|
} from "./platform-tools.mjs";
|
|
20
20
|
import { isLocalApiBase } from "./terminal-layout.mjs";
|
|
21
|
-
|
|
21
|
+
// Only this user's processes (GH #396): another account's gu daemon is not "ours is running".
|
|
22
|
+
const PROC_LIST = processListCommand(process.platform, { ownUser: true });
|
|
22
23
|
|
|
23
24
|
const DIR = join(homedir(), ".gonext");
|
|
24
25
|
export const PID_FILE = join(DIR, "worker.pid");
|
package/doctor-check.mjs
CHANGED
|
@@ -63,6 +63,47 @@ export function rolesFromSettings(s) {
|
|
|
63
63
|
];
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
/**
|
|
67
|
+
* THE MODELS A TURN ACTUALLY RUNS ON (GH #397), from the agent-payload the turn itself fetches.
|
|
68
|
+
*
|
|
69
|
+
* rolesFromSettings reads the legacy single-provider fields. /model now stores the coder as a
|
|
70
|
+
* provider row (agentCodingBackends), so on a machine set up with today's /model the coder read
|
|
71
|
+
* as "not configured" while turns ran on it fine — and the chat model was probed WITHOUT its key,
|
|
72
|
+
* so a working Qwen/OpenAI setup showed "HTTP 401 No API-key provided". Asking the same endpoint
|
|
73
|
+
* a turn asks means /doctor checks what a turn uses, key included.
|
|
74
|
+
*
|
|
75
|
+
* `codingKey` is fetched separately (the payload never carries the coder's key). The embedder is
|
|
76
|
+
* not in the payload, so it still comes from settings. Returns null when there is no payload, so
|
|
77
|
+
* the caller can fall back to rolesFromSettings.
|
|
78
|
+
*/
|
|
79
|
+
export function rolesFromPayload(payload, settings, codingKey = "") {
|
|
80
|
+
if (!payload || typeof payload !== "object") return null;
|
|
81
|
+
const str = (v) => String(v ?? "").trim();
|
|
82
|
+
// "local" is the API's "no key needed" marker, not a credential.
|
|
83
|
+
const realKey = (k) => (str(k) && str(k) !== "local" ? str(k) : "");
|
|
84
|
+
const [, , embed] = rolesFromSettings(settings);
|
|
85
|
+
const agentSet = payload.agentMissing !== true;
|
|
86
|
+
return [
|
|
87
|
+
{
|
|
88
|
+
role: "chat",
|
|
89
|
+
label: "chat model",
|
|
90
|
+
why: "answers and routes every turn",
|
|
91
|
+
url: agentSet ? str(payload.agentBaseURL) : "",
|
|
92
|
+
model: agentSet ? str(payload.agentModelId) : "",
|
|
93
|
+
apiKey: agentSet ? realKey(payload.agentApiKey) : "",
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
role: "code",
|
|
97
|
+
label: "coding model",
|
|
98
|
+
why: "writes the code",
|
|
99
|
+
url: str(payload.codingBaseURL),
|
|
100
|
+
model: str(payload.codingModelId),
|
|
101
|
+
apiKey: realKey(codingKey),
|
|
102
|
+
},
|
|
103
|
+
embed,
|
|
104
|
+
];
|
|
105
|
+
}
|
|
106
|
+
|
|
66
107
|
/**
|
|
67
108
|
* WHY a model that will not answer is not answering, when the host is an Ollama.
|
|
68
109
|
*
|
|
@@ -166,7 +207,8 @@ export async function checkRole(
|
|
|
166
207
|
try {
|
|
167
208
|
res = await fetchImpl(endpoint, {
|
|
168
209
|
method: "POST",
|
|
169
|
-
|
|
210
|
+
// The key goes in the header only — never into the result, which is printed.
|
|
211
|
+
headers: { "Content-Type": "application/json", ...(r.apiKey ? { Authorization: `Bearer ${r.apiKey}` } : {}) },
|
|
170
212
|
body: JSON.stringify(body),
|
|
171
213
|
signal: AbortSignal.timeout(timeoutMs),
|
|
172
214
|
});
|
|
@@ -245,3 +287,23 @@ export function doctorLines(results) {
|
|
|
245
287
|
}
|
|
246
288
|
return out;
|
|
247
289
|
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* THE VERDICT LINE (GH #397). "✓ every configured model answered." was printed when NOTHING was
|
|
293
|
+
* configured — vacuously true, and green on a machine that cannot answer a single question. The
|
|
294
|
+
* check mark is only earned by a model that answered; with none set up the verdict says so and
|
|
295
|
+
* names where to start. Returns { tone: "ok" | "warn" | "bad", text }.
|
|
296
|
+
*/
|
|
297
|
+
export function doctorSummary(results) {
|
|
298
|
+
const list = Array.isArray(results) ? results : [];
|
|
299
|
+
const bad = list.filter((r) => r.status !== "ok" && r.status !== "unset");
|
|
300
|
+
const ok = list.filter((r) => r.status === "ok");
|
|
301
|
+
const unset = list.filter((r) => r.status === "unset");
|
|
302
|
+
if (bad.length) return { tone: "bad", text: `✗ ${bad.length} of ${list.length} did not answer.` };
|
|
303
|
+
if (!ok.length) return { tone: "warn", text: "· no model is set up yet, so nothing was checked — start with /model." };
|
|
304
|
+
if (unset.length) {
|
|
305
|
+
const names = unset.map((r) => r.label).join(", ");
|
|
306
|
+
return { tone: "warn", text: `✓ ${ok.length} answered · not set up: ${names}.` };
|
|
307
|
+
}
|
|
308
|
+
return { tone: "ok", text: "✓ every configured model answered." };
|
|
309
|
+
}
|
package/gonext_agent_chat.py
CHANGED
|
@@ -1707,7 +1707,9 @@ def _coding_backend_flags(backend):
|
|
|
1707
1707
|
# — it applies whenever a folder is registered, i.e. every coding session — and 20 was
|
|
1708
1708
|
# far too low for real work: a NestJS feature routinely spends that on reading and
|
|
1709
1709
|
# installing before it has written a line, so the run stopped to ask mid-task
|
|
1710
|
-
# (user, 2026-08-10: "lets increase it to 100 instead of 20").
|
|
1710
|
+
# (user, 2026-08-10: "lets increase it to 100 instead of 20"). Then 200 for the same reason
|
|
1711
|
+
# (GH #395, user, 2026-11-06: "increase step per turn to 200 instead of 100, only if reach 200
|
|
1712
|
+
# steps it will ask for permission to continue"): the __MAXSTEP__ question is only asked at 200.
|
|
1711
1713
|
#
|
|
1712
1714
|
# Raising it is cheap because the budget is a STOP, not a reservation: a turn that needs
|
|
1713
1715
|
# 12 steps still takes 12. What it changes is how often a working run is interrupted to
|
|
@@ -1716,7 +1718,7 @@ def _coding_backend_flags(backend):
|
|
|
1716
1718
|
#
|
|
1717
1719
|
# The no-workspace defaults stay small on purpose: without a registered folder there are
|
|
1718
1720
|
# no file tools, so a long run there is a model looping rather than making progress.
|
|
1719
|
-
"budget": (20,
|
|
1721
|
+
"budget": (20, 200) if backend == "openai" else (10, 200) if backend == "ollama" else (5, 40),
|
|
1720
1722
|
}
|
|
1721
1723
|
|
|
1722
1724
|
|
|
@@ -6569,17 +6571,24 @@ def _rg_binary(env=None):
|
|
|
6569
6571
|
"""The ripgrep to use, or "" for none. GU_RG=0 turns it off; GU_RG=<path> picks one; then PATH,
|
|
6570
6572
|
then gu's own copy in ~/.gonext/bin."""
|
|
6571
6573
|
import os
|
|
6572
|
-
import shutil
|
|
6573
6574
|
env = os.environ if env is None else env
|
|
6574
6575
|
want = (env.get("GU_RG") or "").strip()
|
|
6575
6576
|
if want.lower() in ("0", "off", "false", "no"):
|
|
6576
6577
|
return ""
|
|
6577
6578
|
if want:
|
|
6578
6579
|
return want if os.path.isfile(want) and os.access(want, os.X_OK) else ""
|
|
6579
|
-
|
|
6580
|
-
|
|
6581
|
-
|
|
6582
|
-
|
|
6580
|
+
# PATH by hand, not shutil.which: on Windows which() looks in the CURRENT folder first, and
|
|
6581
|
+
# gu runs inside the user's project — a repo that ships its own rg.exe would be executed.
|
|
6582
|
+
# Relative PATH entries ("", ".") are skipped for the same reason.
|
|
6583
|
+
exe = "rg.exe" if os.name == "nt" else "rg"
|
|
6584
|
+
for d in (env.get("PATH") or "").split(os.pathsep):
|
|
6585
|
+
d = d.strip().strip('"')
|
|
6586
|
+
if not d or not os.path.isabs(d):
|
|
6587
|
+
continue
|
|
6588
|
+
cand = os.path.join(d, exe)
|
|
6589
|
+
if os.path.isfile(cand) and os.access(cand, os.X_OK):
|
|
6590
|
+
return cand
|
|
6591
|
+
own = os.path.join(os.path.expanduser("~"), ".gonext", "bin", exe)
|
|
6583
6592
|
return own if os.path.isfile(own) and os.access(own, os.X_OK) else ""
|
|
6584
6593
|
|
|
6585
6594
|
|
package/gu-repl.mjs
CHANGED
|
@@ -46,6 +46,7 @@ import dotenv from "dotenv";
|
|
|
46
46
|
import {
|
|
47
47
|
clipVisible,
|
|
48
48
|
createMouseKeyWindow,
|
|
49
|
+
createTurnTypingDetector,
|
|
49
50
|
promptBreakdownRows,
|
|
50
51
|
promptTotal,
|
|
51
52
|
resumeTask,
|
|
@@ -1435,7 +1436,7 @@ rl._ttyWrite = (str, key) => {
|
|
|
1435
1436
|
}
|
|
1436
1437
|
if (verdict.action === "consume") {
|
|
1437
1438
|
mouseClearTimer();
|
|
1438
|
-
applyMouseReport(verdict.report);
|
|
1439
|
+
if (verdict.report) applyMouseReport(verdict.report); // null: focus in/out, a win32 record
|
|
1439
1440
|
return;
|
|
1440
1441
|
}
|
|
1441
1442
|
if (verdict.action === "replay") {
|
|
@@ -3236,8 +3237,8 @@ function frozenZone() {
|
|
|
3236
3237
|
// #258: typing during a turn is not sent — the row says so (an ask() label still comes first).
|
|
3237
3238
|
prompt: secretInput && secretPrompt
|
|
3238
3239
|
? accent(secretPrompt)
|
|
3239
|
-
:
|
|
3240
|
-
? yellow(
|
|
3240
|
+
: turnRowNotice(width) && !inlinePrompt
|
|
3241
|
+
? yellow(turnRowNotice(width))
|
|
3241
3242
|
: inlinePrompt ? accent(inlinePrompt) : PROMPT,
|
|
3242
3243
|
// WHILE A SECRET IS OPEN, THIS ROW IS THE PROMPT — "kimi-k3 API key: ••••" rather than
|
|
3243
3244
|
// ">> ••••". Same row, same masking; what changes is that it now says what it wants. The
|
|
@@ -3246,8 +3247,8 @@ function frozenZone() {
|
|
|
3246
3247
|
promptCols:
|
|
3247
3248
|
secretInput && secretPrompt
|
|
3248
3249
|
? visibleLen(secretPrompt)
|
|
3249
|
-
:
|
|
3250
|
-
? visibleLen(
|
|
3250
|
+
: turnRowNotice(width) && !inlinePrompt
|
|
3251
|
+
? visibleLen(turnRowNotice(width))
|
|
3251
3252
|
: inlinePrompt
|
|
3252
3253
|
? visibleLen(inlinePrompt)
|
|
3253
3254
|
: PROMPT_COLS,
|
|
@@ -4742,7 +4743,11 @@ if (process.stdin.isTTY) {
|
|
|
4742
4743
|
// are part of a payload readline never sees. Without this, every pasted character also
|
|
4743
4744
|
// re-rendered the slash overlay (the line still starts with "/" from before the paste) and
|
|
4744
4745
|
// re-walked the paste store. Same for a mouse report's characters, which are not keys.
|
|
4745
|
-
if (pasteBuffer !== null
|
|
4746
|
+
if (pasteBuffer !== null) return;
|
|
4747
|
+
if (inMouseKeyWindow()) {
|
|
4748
|
+
turnTyping.reset(); // a held "[" was the start of that report (GH #382)
|
|
4749
|
+
return;
|
|
4750
|
+
}
|
|
4746
4751
|
// Issue #120: compacting → every key is locked (Ctrl+C still arrives as SIGINT).
|
|
4747
4752
|
if (compactLocked()) {
|
|
4748
4753
|
rl.line = "";
|
|
@@ -4789,8 +4794,9 @@ if (process.stdin.isTTY) {
|
|
|
4789
4794
|
if (key && key.name === "tab" && key.shift) switchEditMode();
|
|
4790
4795
|
// A printable key (or Enter after one) means the user is typing a question that will not
|
|
4791
4796
|
// be sent — say so in the input row now, not after they have typed the whole thing (#258).
|
|
4792
|
-
|
|
4793
|
-
|
|
4797
|
+
// Not every printable key is typing: a mouse report or cursor reply whose ESC came alone
|
|
4798
|
+
// arrives as "[", "^[[", "[12;5R" (Windows, GH #382) — the detector sits those out.
|
|
4799
|
+
if (turnTyping.feed(_ch, key) && !typedDuringTurn) {
|
|
4794
4800
|
typedDuringTurn = true;
|
|
4795
4801
|
if (barPinned()) paintBar();
|
|
4796
4802
|
}
|
|
@@ -6190,7 +6196,11 @@ async function runModelDoctor() {
|
|
|
6190
6196
|
return;
|
|
6191
6197
|
}
|
|
6192
6198
|
console.log(dim(" Asking each model to answer — the real request, not just a ping."));
|
|
6193
|
-
|
|
6199
|
+
// The SAME models, URLs and keys a turn gets (GH #397): this folder's /model pick included.
|
|
6200
|
+
const turnPayload = await fetchAgentPayload([{ role: "user", content: "ping" }], sessionCodingModel).catch(() => null);
|
|
6201
|
+
const p = turnPayload?.payload ?? null;
|
|
6202
|
+
const codingKey = p?.codingBackendId ? (await fetchKeyForBackend(p.codingBackendId))?.apiKey ?? "" : "";
|
|
6203
|
+
const roles = dc.rolesFromPayload(p, settings, codingKey) ?? dc.rolesFromSettings(settings);
|
|
6194
6204
|
const results = [];
|
|
6195
6205
|
for (const r of roles) {
|
|
6196
6206
|
// One at a time, and SAID one at a time: these share a GPU, so a check can take a while and
|
|
@@ -6207,13 +6217,8 @@ async function runModelDoctor() {
|
|
|
6207
6217
|
console.log(GUTTER + (res.status === "ok" ? dim(l) : res.status === "unset" ? dim(l) : yellow(l)));
|
|
6208
6218
|
}
|
|
6209
6219
|
}
|
|
6210
|
-
const
|
|
6211
|
-
console.log(
|
|
6212
|
-
"\n" + GUTTER +
|
|
6213
|
-
(bad.length === 0
|
|
6214
|
-
? accent("✓ every configured model answered.")
|
|
6215
|
-
: red(`✗ ${bad.length} of ${results.length} did not answer.`))
|
|
6216
|
-
);
|
|
6220
|
+
const verdict0 = dc.doctorSummary(results);
|
|
6221
|
+
console.log("\n" + GUTTER + (verdict0.tone === "ok" ? accent : verdict0.tone === "bad" ? red : yellow)(verdict0.text));
|
|
6217
6222
|
// THE GU SERVICE TOO (#413): every model can answer and a coding turn still fail, because its
|
|
6218
6223
|
// instructions come from the gu service — reported on a Windows VM where only "hi" worked.
|
|
6219
6224
|
const sc = await import("./service-check.mjs");
|
|
@@ -6286,10 +6291,16 @@ async function doctorFetch(url, init) {
|
|
|
6286
6291
|
* Returns [] when there is nothing to offer, or null when the user cancelled — which the caller
|
|
6287
6292
|
* must treat as "abandon", not as "offer none", or Escape would silently save something.
|
|
6288
6293
|
*/
|
|
6289
|
-
async function pickExtraModels(
|
|
6290
|
-
|
|
6291
|
-
|
|
6294
|
+
async function pickExtraModels(rest0, preChecked = []) {
|
|
6295
|
+
// The SAME non-coding filter as the main pick (GH #397): the extras are coders too, and this
|
|
6296
|
+
// list offered image, audio and video models the picker just above had hidden.
|
|
6292
6297
|
const already = new Set(preChecked);
|
|
6298
|
+
const { codingModelCandidates } = await import("./coding-provider-setup.mjs");
|
|
6299
|
+
const keep = new Set(codingModelCandidates(rest0).models);
|
|
6300
|
+
const rest = rest0.filter((m) => keep.has(m) || already.has(m));
|
|
6301
|
+
if (rest.length === 0) return [];
|
|
6302
|
+
console.log(dim(` Also offer these in /model? (up to ${MAX_ALT_CODERS})`));
|
|
6303
|
+
console.log(dim(` ${PICK_MANY_HINT}`));
|
|
6293
6304
|
const ticks = await pickManyFromList(
|
|
6294
6305
|
rest,
|
|
6295
6306
|
rest.map((m, i) => (already.has(m) ? i : -1)).filter((i) => i >= 0),
|
|
@@ -6327,11 +6338,11 @@ async function chooseCodingModelFrom(ps, all, question, current, { back = false
|
|
|
6327
6338
|
// Opens on the CURRENT model where there is one — a long list that opens at the top tells the
|
|
6328
6339
|
// reader nothing about which one they are already using.
|
|
6329
6340
|
const at = typeof current === "string" ? list.indexOf(current) : Number(current) || 0;
|
|
6330
|
-
|
|
6331
|
-
|
|
6332
|
-
|
|
6333
|
-
);
|
|
6334
|
-
const idx = await pickFromList(rows, at >= 0 ? at : 0, { back });
|
|
6341
|
+
// A HEADER, not a printed line (GH #397): erased with the list, so a finished pick leaves
|
|
6342
|
+
// "→ model" behind instead of a question nobody is being asked any more.
|
|
6343
|
+
const head = [dim(` ${question} (${PICK_HINT})`)];
|
|
6344
|
+
if (!showingAll && filtered) head.push(dim(` ${filtered} non-coding models hidden — "show all" is the last row`));
|
|
6345
|
+
const idx = await pickFromList(rows, at >= 0 ? at : 0, { back, header: () => head, headerRows: head.length });
|
|
6335
6346
|
if (idx === PICK_BACK) return BACK;
|
|
6336
6347
|
if (idx < 0) return null;
|
|
6337
6348
|
if (idx === list.length) {
|
|
@@ -6343,6 +6354,7 @@ async function chooseCodingModelFrom(ps, all, question, current, { back = false
|
|
|
6343
6354
|
showingAll = true;
|
|
6344
6355
|
continue;
|
|
6345
6356
|
}
|
|
6357
|
+
console.log(dim(` → ${list[idx]}`));
|
|
6346
6358
|
return list[idx];
|
|
6347
6359
|
}
|
|
6348
6360
|
}
|
|
@@ -6363,14 +6375,18 @@ async function chooseCodingModelFrom(ps, all, question, current, { back = false
|
|
|
6363
6375
|
async function chooseFromModelList(all, question, current = 0, { back = false } = {}) {
|
|
6364
6376
|
const rows = [...all, "type a model name…"];
|
|
6365
6377
|
const at = typeof current === "string" ? all.indexOf(current) : Number(current) || 0;
|
|
6366
|
-
|
|
6367
|
-
|
|
6378
|
+
const idx = await pickFromList(rows, at >= 0 ? at : 0, {
|
|
6379
|
+
back,
|
|
6380
|
+
header: () => [dim(` ${question} (${PICK_HINT})`)],
|
|
6381
|
+
headerRows: 1,
|
|
6382
|
+
});
|
|
6368
6383
|
if (idx === PICK_BACK) return BACK;
|
|
6369
6384
|
if (idx < 0) return null;
|
|
6370
6385
|
if (idx === all.length) {
|
|
6371
6386
|
const typed = (await ask(" model name: ")).trim();
|
|
6372
6387
|
return typed || null;
|
|
6373
6388
|
}
|
|
6389
|
+
console.log(dim(` → ${all[idx]}`));
|
|
6374
6390
|
return all[idx];
|
|
6375
6391
|
}
|
|
6376
6392
|
|
|
@@ -6399,8 +6415,13 @@ async function manageCodingProviders({ back = false } = {}) {
|
|
|
6399
6415
|
);
|
|
6400
6416
|
const ADD = labels.length;
|
|
6401
6417
|
labels.push(actionRow("add another provider — Claude API, OpenAI, Kimi…"));
|
|
6402
|
-
|
|
6403
|
-
|
|
6418
|
+
// Headers, not printed lines (GH #397): going one level deeper must not leave the level above
|
|
6419
|
+
// stacked on screen.
|
|
6420
|
+
const idx = await pickFromList(labels, selAt, {
|
|
6421
|
+
back,
|
|
6422
|
+
header: () => [dim(` Providers — pick one to change it, or add another (${PICK_HINT}):`)],
|
|
6423
|
+
headerRows: 1,
|
|
6424
|
+
});
|
|
6404
6425
|
if (idx === PICK_BACK) return BACK;
|
|
6405
6426
|
if (idx < 0) {
|
|
6406
6427
|
console.log(dim(" (cancelled)\n"));
|
|
@@ -6414,11 +6435,10 @@ async function manageCodingProviders({ back = false } = {}) {
|
|
|
6414
6435
|
|
|
6415
6436
|
const p = providers[idx];
|
|
6416
6437
|
for (;;) {
|
|
6417
|
-
console.log("\n" + GUTTER + dim(`${p.host} · ${p.model || "no model set"}`));
|
|
6418
6438
|
const action = await pickFromList(
|
|
6419
6439
|
[dim("keep it"), "change the model…", "change the URL…", "remove this provider…"],
|
|
6420
6440
|
1,
|
|
6421
|
-
{ back: true }
|
|
6441
|
+
{ back: true, header: () => [GUTTER + dim(`${p.host} · ${p.model || "no model set"}`)], headerRows: 1 }
|
|
6422
6442
|
);
|
|
6423
6443
|
if (action === PICK_BACK) break; // → the provider list
|
|
6424
6444
|
if (action <= 0) {
|
|
@@ -6542,7 +6562,7 @@ async function changeProviderModel(ps, rows, p) {
|
|
|
6542
6562
|
await syncActiveCoderWhere();
|
|
6543
6563
|
console.log(GUTTER + dim("this folder's pick named the old model — cleared it."));
|
|
6544
6564
|
}
|
|
6545
|
-
console.log(accent(`
|
|
6565
|
+
console.log(accent(` ✓ ${p.host} now uses ${model}`) + dim(" · /model switches between its models\n"));
|
|
6546
6566
|
}
|
|
6547
6567
|
|
|
6548
6568
|
/** Point a provider at a different URL, keeping its id so saved picks still resolve. */
|
|
@@ -6633,6 +6653,18 @@ async function removeCodingProvider(ps, rows, p) {
|
|
|
6633
6653
|
);
|
|
6634
6654
|
console.log(GUTTER + dim(" they will be deleted too; a key with no provider can never be used again.\n"));
|
|
6635
6655
|
}
|
|
6656
|
+
// THE AGENT MAY TALK THROUGH THIS PROVIDER TOO (GH #397). Removing the row and its key left
|
|
6657
|
+
// agentModelUrl pointing at it: the banner still named the model, every question failed on a
|
|
6658
|
+
// missing key, and /agent-model probed the URL as an Ollama (…/api/tags → 404). So the agent
|
|
6659
|
+
// model goes with it — said before the question, like the keys.
|
|
6660
|
+
const settings = await fetchWorkerSettings().catch(() => null);
|
|
6661
|
+
const sameUrl = (u) => u && ps.ensureV1Root(String(u)).toLowerCase() === ps.ensureV1Root(String(p.url ?? "")).toLowerCase();
|
|
6662
|
+
const agentUses = sameUrl(settings?.agentModelUrl);
|
|
6663
|
+
if (agentUses) {
|
|
6664
|
+
const name = String(settings.agentModelName || settings.agentModel || "").trim();
|
|
6665
|
+
console.log(GUTTER + orange(` the agent model uses it too${name ? ` — ${name}` : ""}`));
|
|
6666
|
+
console.log(GUTTER + dim(" it will be unset; pick another with /agent-model.\n"));
|
|
6667
|
+
}
|
|
6636
6668
|
const yes = await askYesNo(
|
|
6637
6669
|
`Remove ${p.host} (${p.model || "no model set"})? This cannot be undone.`,
|
|
6638
6670
|
false
|
|
@@ -6644,6 +6676,7 @@ async function removeCodingProvider(ps, rows, p) {
|
|
|
6644
6676
|
const saved = await withPendingStatus("removing", () =>
|
|
6645
6677
|
md.saveWorkerSettings(apiBase, workerKey, {
|
|
6646
6678
|
agentCodingBackends: ps.removeProvider(rows, p.id),
|
|
6679
|
+
...(agentUses ? { agentModelUrl: "", agentModelName: "", agentModel: "" } : {}),
|
|
6647
6680
|
})
|
|
6648
6681
|
);
|
|
6649
6682
|
if (!saved) {
|
|
@@ -6660,6 +6693,7 @@ async function removeCodingProvider(ps, rows, p) {
|
|
|
6660
6693
|
sessionCodingKeyId = "";
|
|
6661
6694
|
await saveSessionCodingKeyId(resolve(process.cwd()));
|
|
6662
6695
|
}
|
|
6696
|
+
if (k.id === sessionAgentKeyId) await saveFolderKeyPick("agent", "");
|
|
6663
6697
|
}
|
|
6664
6698
|
// This folder may have been pointed at the removed provider.
|
|
6665
6699
|
if (sessionCodingUrl === p.url || sessionCodingModel.startsWith(`${p.id}::`)) {
|
|
@@ -6673,8 +6707,21 @@ async function removeCodingProvider(ps, rows, p) {
|
|
|
6673
6707
|
console.log(
|
|
6674
6708
|
accent(` ✓ removed ${p.host}`) +
|
|
6675
6709
|
(stranded.length ? dim(` · ${deleted} key${deleted === 1 ? "" : "s"} deleted`) : "") +
|
|
6710
|
+
(agentUses ? dim(" · agent model unset") : "") +
|
|
6676
6711
|
"\n"
|
|
6677
6712
|
);
|
|
6713
|
+
// The bar named the removed coder (the account default is held in memory): read it back.
|
|
6714
|
+
if (sameUrl(defaultCodingUrl)) {
|
|
6715
|
+
defaultCodingModel = "";
|
|
6716
|
+
defaultCodingUrl = "";
|
|
6717
|
+
}
|
|
6718
|
+
const after = (await fetchAgentPayload([{ role: "user", content: "." }]).catch(() => null))?.payload;
|
|
6719
|
+
if (after) {
|
|
6720
|
+
seedDefaultCoder(after);
|
|
6721
|
+
agentMissing = after.agentMissing === true;
|
|
6722
|
+
noModelAtAll = nothingToAnswerWith(after);
|
|
6723
|
+
}
|
|
6724
|
+
if (barPinned()) paintBar();
|
|
6678
6725
|
}
|
|
6679
6726
|
|
|
6680
6727
|
/**
|
|
@@ -6730,7 +6777,8 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
|
|
|
6730
6777
|
const providerPicker = !presetUrl && dialect === "openai";
|
|
6731
6778
|
const ec = role === "embed" ? await import("./embed-model-command.mjs") : null;
|
|
6732
6779
|
let target, host, found, need, key, keyName, typedKey, models, model;
|
|
6733
|
-
|
|
6780
|
+
let lastPick = 0; // GH #397: back from the key re-opens the list ON the provider just chosen
|
|
6781
|
+
providerLoop: for (;;) {
|
|
6734
6782
|
// --- 1. the server --------------------------------------------------------------------
|
|
6735
6783
|
target = null;
|
|
6736
6784
|
// The dialect the URL prompt reads the address in: the caller's, or Ollama's when that row was picked.
|
|
@@ -6748,22 +6796,31 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
|
|
|
6748
6796
|
const presets = ps.providerPresets(role);
|
|
6749
6797
|
// #244: an Ollama on this machine or the network is a row here, like the hosted ones — the
|
|
6750
6798
|
// menus no longer have a separate "use an Ollama server…" entrance. It asks for the address.
|
|
6751
|
-
|
|
6799
|
+
// OLLAMA FIRST (GH #397): first-run setup lists "Ollama server — this machine or your own"
|
|
6800
|
+
// first and these pickers listed it seventh, so the same choice moved between two screens.
|
|
6801
|
+
// gu is local-first; the hosted providers follow in their usual order.
|
|
6802
|
+
const OLLAMA_HERE = 0;
|
|
6752
6803
|
const rowsShown = [
|
|
6753
|
-
...presets.map((p) => `${p.name}${dim(` · ${p.url}`)}`),
|
|
6754
6804
|
`Ollama — this machine or your network…${dim(" · e.g. 127.0.0.1:11434")}`,
|
|
6805
|
+
...presets.map((p) => `${p.name}${dim(` · ${p.url}`)}`),
|
|
6755
6806
|
"another URL…",
|
|
6756
6807
|
];
|
|
6757
6808
|
// A preset names its own dialect (Ollama cloud is "ollama"); the rest are OpenAI-compatible.
|
|
6758
|
-
|
|
6759
|
-
|
|
6809
|
+
// The question is the picker's HEADER, erased with it (GH #397): printed above, it stayed in
|
|
6810
|
+
// the history every time "← back" re-opened this list — "Which provider?" stacked up.
|
|
6811
|
+
const pi = await pickFromList(rowsShown, lastPick, { back, header: () => [dim(` Which provider? (${PICK_HINT})`)], headerRows: 1 });
|
|
6760
6812
|
if (pi === PICK_BACK) return BACK;
|
|
6761
6813
|
if (pi < 0) {
|
|
6762
6814
|
console.log(dim(" (nothing saved)\n"));
|
|
6763
6815
|
return;
|
|
6764
6816
|
}
|
|
6765
|
-
|
|
6817
|
+
lastPick = pi;
|
|
6818
|
+
const preset = pi > OLLAMA_HERE && pi <= presets.length ? presets[pi - 1] : null;
|
|
6819
|
+
if (preset) target = as.serverTarget(preset.dialect ?? "openai", preset.url);
|
|
6766
6820
|
else if (pi === OLLAMA_HERE) askDialect = "ollama";
|
|
6821
|
+
// SAY WHAT WAS PICKED (GH #397): the list is erased on Enter, and what followed ("… needs an
|
|
6822
|
+
// API key", "key name:") never named the provider — the choice was nowhere on screen.
|
|
6823
|
+
console.log(GUTTER + dim(`→ ${preset ? preset.name : pi === OLLAMA_HERE ? "Ollama" : "another URL"}`));
|
|
6767
6824
|
}
|
|
6768
6825
|
if (!target && !presetUrl) {
|
|
6769
6826
|
const dflt = as.defaultServerUrl(role, askDialect, settings, existing);
|
|
@@ -6829,8 +6886,19 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
|
|
|
6829
6886
|
if (key) console.log(GUTTER + dim(`using the key "${inUse.name}" already saved for ${host}.`));
|
|
6830
6887
|
}
|
|
6831
6888
|
}
|
|
6832
|
-
|
|
6833
|
-
|
|
6889
|
+
// ESC AT THE KEY GOES BACK ONE STEP (GH #397): with a provider picker behind it, Esc at
|
|
6890
|
+
// "key name:" / "API key:" returns to "Which provider?" — it used to unwind the whole command,
|
|
6891
|
+
// so choosing the wrong provider meant starting /agent-model or /model again. Nothing is saved
|
|
6892
|
+
// before step 5, so going back leaves nothing behind. Without a picker it still cancels.
|
|
6893
|
+
const BACK_TO_PROVIDERS = Symbol("back");
|
|
6894
|
+
const askKey = async ({ again = false } = {}) => {
|
|
6895
|
+
let t;
|
|
6896
|
+
try {
|
|
6897
|
+
t = await askServerKey({ host, optional: need === "optional", escBack: providerPicker, ...(again && typedKey ? { name: keyName } : {}) });
|
|
6898
|
+
} catch (e) {
|
|
6899
|
+
if (wasPromptCancelled(e) && providerPicker) return BACK_TO_PROVIDERS;
|
|
6900
|
+
throw e;
|
|
6901
|
+
}
|
|
6834
6902
|
key = t.secret;
|
|
6835
6903
|
keyName = t.name;
|
|
6836
6904
|
typedKey = Boolean(t.secret);
|
|
@@ -6838,7 +6906,9 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
|
|
|
6838
6906
|
};
|
|
6839
6907
|
if (!key && (need !== "none" || newKey)) {
|
|
6840
6908
|
if (need === "required") console.log(GUTTER + dim(`${host} needs an API key.`));
|
|
6841
|
-
|
|
6909
|
+
const got = await askKey();
|
|
6910
|
+
if (got === BACK_TO_PROVIDERS) continue providerLoop;
|
|
6911
|
+
if (!got) return;
|
|
6842
6912
|
}
|
|
6843
6913
|
|
|
6844
6914
|
// --- 3. its models, listed with that key ----------------------------------------------
|
|
@@ -6855,10 +6925,18 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
|
|
|
6855
6925
|
}
|
|
6856
6926
|
});
|
|
6857
6927
|
let probe = await list();
|
|
6858
|
-
//
|
|
6859
|
-
|
|
6860
|
-
|
|
6861
|
-
|
|
6928
|
+
// A REFUSED KEY IS ASKED FOR AGAIN, HERE (GH #397). The stored one always was; a TYPED one
|
|
6929
|
+
// ended the command with "check the key and try again" — which meant /model, the provider and
|
|
6930
|
+
// the key name all over again for one mistyped character. Two more tries, then stop.
|
|
6931
|
+
for (let retry = 0; retry < 2 && (probe.status === 401 || probe.status === 403) && key; retry++) {
|
|
6932
|
+
// Two short lines, not one long one: at 80 columns the single sentence wrapped mid-word.
|
|
6933
|
+
console.log(yellow(typedKey
|
|
6934
|
+
? ` ${host} rejected that key (HTTP ${probe.status}).`
|
|
6935
|
+
: ` ${host} refused the stored key (HTTP ${probe.status}).`));
|
|
6936
|
+
console.log(GUTTER + dim(providerPicker ? "paste the right one — Esc goes back to the providers." : "paste the right one — Esc cancels."));
|
|
6937
|
+
const got = await askKey({ again: true });
|
|
6938
|
+
if (got === BACK_TO_PROVIDERS) continue providerLoop;
|
|
6939
|
+
if (!got || !key) return;
|
|
6862
6940
|
probe = await list();
|
|
6863
6941
|
}
|
|
6864
6942
|
if (probe.status || probe.err) {
|
|
@@ -6935,6 +7013,7 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
|
|
|
6935
7013
|
}
|
|
6936
7014
|
|
|
6937
7015
|
// --- 5. save: the server, its key, then the role --------------------------------------
|
|
7016
|
+
let alsoInModel = false;
|
|
6938
7017
|
let rowId = found?.id ?? "";
|
|
6939
7018
|
let rows = existing;
|
|
6940
7019
|
// A NEW ROW ONLY WHEN SOMETHING NEEDS ONE: the coder is picked through its row, and a key is
|
|
@@ -6961,7 +7040,8 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
|
|
|
6961
7040
|
console.log(GUTTER + dim("if this machine's local API is old, restart it with `gu api restart`.\n"));
|
|
6962
7041
|
return;
|
|
6963
7042
|
}
|
|
6964
|
-
|
|
7043
|
+
// Said AFTER the ✓ (GH #397) — printed here it came before the result it is a footnote to.
|
|
7044
|
+
if (!found && role !== "coding" && as.serverRole(role).rowModel) alsoInModel = true;
|
|
6965
7045
|
}
|
|
6966
7046
|
if (typedKey) {
|
|
6967
7047
|
const id = await withPendingStatus("saving the key", () => addCodingKey(keyName, key, rowId));
|
|
@@ -6974,14 +7054,15 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
|
|
|
6974
7054
|
// the stored one was refused: it is the server's key from now on, for every role and folder.
|
|
6975
7055
|
if (found) await setServerDefaultKey(rowId, id);
|
|
6976
7056
|
}
|
|
6977
|
-
const keyLine = key ? dim(`
|
|
7057
|
+
const keyLine = key ? dim(` API key ${keyName || "saved"} ····${key.slice(-4)}`) : "";
|
|
6978
7058
|
|
|
6979
7059
|
if (role === "agent") {
|
|
6980
7060
|
// A MACHINE SETTING, not a folder pick: the agent is what gu talks to in every folder. Saved
|
|
6981
7061
|
// as URL + NAME, the pair app.ts matches back to the server row to find the key.
|
|
6982
|
-
|
|
6983
|
-
|
|
6984
|
-
|
|
7062
|
+
// ONE BLOCK under the ✓ (GH #397): the key, then the footnotes — not three loose paragraphs.
|
|
7063
|
+
const extra = [keyLine, alsoInModel ? dim(` ${host} is also offered as a coding model in /model.`) : ""];
|
|
7064
|
+
if (await saveAgentModel(target.roleUrl("agent"), model, host, extra.filter(Boolean))) {
|
|
7065
|
+
/* said */
|
|
6985
7066
|
} else if (rows !== existing || typedKey) {
|
|
6986
7067
|
// The SERVER landed — say so, or the user re-enters a key that is already stored.
|
|
6987
7068
|
console.log(yellow(` ${host} was saved, but the agent model was not pointed at it.`));
|
|
@@ -7175,11 +7256,13 @@ async function chooseModel() {
|
|
|
7175
7256
|
// choices while still occupying its URL, so sending this branch straight to the ADD flow
|
|
7176
7257
|
// would meet "already configured here" and dead-end. The manager fixes such a row, and falls
|
|
7177
7258
|
// through to add when there is genuinely nothing there.
|
|
7178
|
-
|
|
7259
|
+
// As the picker's header (GH #397), so "← back" re-opening this menu does not stack it.
|
|
7179
7260
|
const go = await pickFromList(
|
|
7180
7261
|
[dim("leave it"), "add or change a provider — Claude API, OpenAI, Kimi…"],
|
|
7181
|
-
1
|
|
7262
|
+
1,
|
|
7263
|
+
{ header: () => [dim(" No coding model configured.")], headerRows: 1 }
|
|
7182
7264
|
);
|
|
7265
|
+
if (go !== 1) console.log(dim(" No coding model configured."));
|
|
7183
7266
|
if (go === 1 && (await manageCodingProviders({ back: true })) === BACK) return chooseModel();
|
|
7184
7267
|
if (go !== 1) console.log("");
|
|
7185
7268
|
return;
|
|
@@ -7233,8 +7316,11 @@ async function chooseModel() {
|
|
|
7233
7316
|
// enough in name to have already been mixed up. The fix is to say which one THIS is — not to
|
|
7234
7317
|
// print the other two here: the agent model and the embedder are independent settings with
|
|
7235
7318
|
// their own commands, and listing them together implies a relationship that does not exist.
|
|
7236
|
-
|
|
7237
|
-
|
|
7319
|
+
const idx = await pickFromList(labels, activeIdx >= 0 ? activeIdx : 0, {
|
|
7320
|
+
footer: ESC_FOOTER,
|
|
7321
|
+
header: () => [dim(` Choose the CODING model — the one that writes code (${PICK_ARROWS}):`)],
|
|
7322
|
+
headerRows: 1,
|
|
7323
|
+
});
|
|
7238
7324
|
if (idx < 0) {
|
|
7239
7325
|
console.log(dim(" (cancelled)\n"));
|
|
7240
7326
|
return;
|
|
@@ -8869,8 +8955,13 @@ async function refreshLogsSize() {
|
|
|
8869
8955
|
// What is collected and how it is sent is support-bundle.mjs; this is the terminal half only.
|
|
8870
8956
|
let supportBusy = false;
|
|
8871
8957
|
async function runSupport() {
|
|
8958
|
+
// DURING A TURN (GH #382): it used to print one dim line into the turn's output — easy to
|
|
8959
|
+
// miss, then nothing happened, and a stuck turn is exactly when someone wants support. Now it
|
|
8960
|
+
// is QUEUED: the input row says so (with the way out, Ctrl-C) and it opens when the turn ends.
|
|
8872
8961
|
if (following) {
|
|
8873
|
-
|
|
8962
|
+
supportAfterTurn = true;
|
|
8963
|
+
if (barPinned()) paintBar();
|
|
8964
|
+
else console.log("\n" + GUTTER + yellow(SUPPORT_AFTER_TURN_NOTICE.trim()));
|
|
8874
8965
|
return;
|
|
8875
8966
|
}
|
|
8876
8967
|
if (supportBusy || listPickerActive || approvalActive || askWaiting) return;
|
|
@@ -9153,7 +9244,14 @@ async function chooseEmbedModel() {
|
|
|
9153
9244
|
if (keyRequired) labels.push(actionRow(`API keys for ${host || currentUrl} — switch, add or remove…`));
|
|
9154
9245
|
const ADD = labels.length;
|
|
9155
9246
|
labels.push(actionRow("add a provider — OpenAI, Ollama…"));
|
|
9156
|
-
|
|
9247
|
+
// NOTHING SET IS SAID AS NOTHING SET (GH #397). "now nomic-embed-text:latest on 127.0.0.1:11434"
|
|
9248
|
+
// was the DEFAULT, printed as if it were in use — on a Mac with no Ollama, where /doctor said
|
|
9249
|
+
// "embedder (no model) — no server configured" a moment later. The default still drives the
|
|
9250
|
+
// cursor and the "pick another model on …" row; the header only claims what is set.
|
|
9251
|
+
const embedConfigured = isSet || Boolean(String(settings.ragEmbedUrl ?? "").trim());
|
|
9252
|
+
const head = embedConfigured
|
|
9253
|
+
? ` Embedding model (now ${current} on ${host || currentUrl})`
|
|
9254
|
+
: " Embedding model — none set up yet, so RAG is off. Pick one";
|
|
9157
9255
|
const hint = `(${PICK_ARROWS}):`;
|
|
9158
9256
|
const cols = Math.max(20, process.stdout.columns || 80);
|
|
9159
9257
|
console.log(dim(head.length + 2 + hint.length < cols ? `${head} ${hint}` : `${head}\n ${hint}`));
|
|
@@ -9207,7 +9305,7 @@ async function chooseEmbedModel() {
|
|
|
9207
9305
|
}
|
|
9208
9306
|
|
|
9209
9307
|
/** Save the agent model on this machine: the URL the API sends turns to, and the model name. */
|
|
9210
|
-
async function saveAgentModel(agentUrl, model, host) {
|
|
9308
|
+
async function saveAgentModel(agentUrl, model, host, extra = []) {
|
|
9211
9309
|
const md = await import("./model-doctor.mjs");
|
|
9212
9310
|
const ok = await withPendingStatus("pointing the agent model at it", () =>
|
|
9213
9311
|
md.saveWorkerSettings(apiBase, workerKey, { agentModelUrl: agentUrl, agentModelName: model, agentModel: model })
|
|
@@ -9218,6 +9316,7 @@ async function saveAgentModel(agentUrl, model, host) {
|
|
|
9218
9316
|
return false;
|
|
9219
9317
|
}
|
|
9220
9318
|
console.log(accent(` ✓ agent model → ${model}`) + dim(` · ${host}`));
|
|
9319
|
+
for (const l of extra) console.log(l);
|
|
9221
9320
|
console.log(dim(" takes effect on your next question.\n"));
|
|
9222
9321
|
return true;
|
|
9223
9322
|
}
|
|
@@ -9226,7 +9325,9 @@ async function saveAgentModel(agentUrl, model, host) {
|
|
|
9226
9325
|
* Ask for a key's NAME — and refuse the key itself as a name: names are printed wherever keys are
|
|
9227
9326
|
* listed, so a pasted secret there is a secret on screen (reported: "API key sk-E8Iv… ····3E66").
|
|
9228
9327
|
*/
|
|
9229
|
-
|
|
9328
|
+
// "(optional)" because nothing said so (GH #397): a bare "key name:" with no default reads as a
|
|
9329
|
+
// required field, while Enter has always meant "no name".
|
|
9330
|
+
async function askKeyName(prompt = " key name (optional): ") {
|
|
9230
9331
|
const am = await import("./agent-model-command.mjs");
|
|
9231
9332
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
9232
9333
|
const name = (await ask(prompt)).trim();
|
|
@@ -9247,13 +9348,15 @@ async function askKeyName(prompt = " key name: ") {
|
|
|
9247
9348
|
* "no key" (a local vLLM answers anonymously). `host` names the server in the prompt and decides
|
|
9248
9349
|
* the shape check's locality: a local server accepts any string, so "abc" is not questioned there.
|
|
9249
9350
|
*/
|
|
9250
|
-
async function askServerKey({ host = "", optional = false } = {}) {
|
|
9351
|
+
async function askServerKey({ host = "", optional = false, escBack = false, name: knownName = null } = {}) {
|
|
9251
9352
|
const ps = await import("./coding-provider-setup.mjs");
|
|
9252
9353
|
// Esc is said here (#249): the prompt that follows is bare text, with nothing on it saying a
|
|
9253
9354
|
// way out exists — Esc cancels the whole add, nothing saved. (The line explaining what a key
|
|
9254
9355
|
// name is for was removed on request.)
|
|
9255
|
-
|
|
9256
|
-
|
|
9356
|
+
// GH #397: where a provider list is behind this prompt, Esc returns to it — and says so.
|
|
9357
|
+
// A RETRY KEEPS THE NAME (GH #397): only the secret was wrong, so only the secret is asked.
|
|
9358
|
+
if (knownName === null) console.log("\n" + GUTTER + dim(escBack ? "Esc goes back to the providers." : "Esc to cancel."));
|
|
9359
|
+
const name = knownName === null ? (await askKeyName()).trim() : String(knownName);
|
|
9257
9360
|
console.log(GUTTER + dim(SECRET_KEY_HINT));
|
|
9258
9361
|
const secret = (await askSecret(` API key${host ? ` for ${host}` : ""}${name ? ` (${name})` : ""}: `)).trim();
|
|
9259
9362
|
if (!secret) {
|
|
@@ -9488,7 +9591,7 @@ async function agentModelOnServer({ back = false, start = "menu" } = {}) {
|
|
|
9488
9591
|
const options = am.agentRecoveryOptions({ keyProblem, host: listHost });
|
|
9489
9592
|
const go = await pickFromList(
|
|
9490
9593
|
options.map((o, i) => (i === 0 ? dim(o.label) : o.label)),
|
|
9491
|
-
|
|
9594
|
+
am.agentRecoveryDefault(options, { keyProblem, configured: Boolean(url || current) }),
|
|
9492
9595
|
{ back }
|
|
9493
9596
|
);
|
|
9494
9597
|
if (go === PICK_BACK) return BACK;
|
|
@@ -11148,11 +11251,28 @@ let following = false;
|
|
|
11148
11251
|
// typed while the previous one was still queued simply vanished. Now the input row says so the
|
|
11149
11252
|
// moment a key lands, and keeps saying it until the turn ends. Nothing is queued or sent.
|
|
11150
11253
|
let typedDuringTurn = false;
|
|
11254
|
+
const turnTyping = createTurnTypingDetector(); // what counts as typing during a turn (GH #382)
|
|
11151
11255
|
const TYPED_DURING_TURN_NOTICE = "(a turn is running — typing is not sent; type it again when it finishes) ";
|
|
11152
11256
|
const TYPED_DURING_TURN_SHORT = "(turn running — not sent) ";
|
|
11257
|
+
// "? support" clicked during a turn (GH #382): it opens when the turn ends; Ctrl-C ends it now.
|
|
11258
|
+
let supportAfterTurn = false;
|
|
11259
|
+
const SUPPORT_AFTER_TURN_NOTICE = "(support opens when this turn ends — Ctrl-C stops the turn now) ";
|
|
11260
|
+
const SUPPORT_AFTER_TURN_SHORT = "(support after this turn — Ctrl-C) ";
|
|
11261
|
+
// After the first Ctrl-C the turn is stopping; a worker that never answers needs a second one.
|
|
11262
|
+
const SUPPORT_AFTER_STOP_NOTICE = "(support opens when this turn ends — Ctrl-C again to stop waiting) ";
|
|
11263
|
+
const SUPPORT_AFTER_STOP_SHORT = "(support after this turn — Ctrl-C again) ";
|
|
11153
11264
|
/** The notice that fits: the input row must never wrap because of its label (#109/#132). */
|
|
11154
11265
|
const typedNotice = (width) =>
|
|
11155
11266
|
visibleLen(TYPED_DURING_TURN_NOTICE) + 12 <= width ? TYPED_DURING_TURN_NOTICE : TYPED_DURING_TURN_SHORT;
|
|
11267
|
+
/** What the input row says during a turn instead of ">>", or "" — queued support comes first. */
|
|
11268
|
+
const turnRowNotice = (width) =>
|
|
11269
|
+
!following
|
|
11270
|
+
? ""
|
|
11271
|
+
: supportAfterTurn
|
|
11272
|
+
? cancelRequested
|
|
11273
|
+
? visibleLen(SUPPORT_AFTER_STOP_NOTICE) + 12 <= width ? SUPPORT_AFTER_STOP_NOTICE : SUPPORT_AFTER_STOP_SHORT
|
|
11274
|
+
: visibleLen(SUPPORT_AFTER_TURN_NOTICE) + 12 <= width ? SUPPORT_AFTER_TURN_NOTICE : SUPPORT_AFTER_TURN_SHORT
|
|
11275
|
+
: typedDuringTurn ? typedNotice(width) : "";
|
|
11156
11276
|
// Is the turn we are following one this terminal STARTED, or another terminal's that we merely
|
|
11157
11277
|
// attached to (task #211)? `attached` is local to runAgentTurn, and onInterrupt — which lives in
|
|
11158
11278
|
// main() — had no way to ask. So Ctrl+C while watching somebody else's agent took the "cancel my
|
|
@@ -12546,6 +12666,7 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
12546
12666
|
following = true;
|
|
12547
12667
|
followingAttached = attached;
|
|
12548
12668
|
typedDuringTurn = false;
|
|
12669
|
+
turnTyping.reset();
|
|
12549
12670
|
compactSeenPasses = 0; // issue #120: python counts passes per turn
|
|
12550
12671
|
followAborted = false;
|
|
12551
12672
|
currentJobId = jobId;
|
|
@@ -13901,6 +14022,7 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
13901
14022
|
following = false;
|
|
13902
14023
|
followingAttached = false;
|
|
13903
14024
|
typedDuringTurn = false;
|
|
14025
|
+
turnTyping.reset();
|
|
13904
14026
|
endCompactLock(); // issue #120: never leave the terminal locked after a turn
|
|
13905
14027
|
currentJobId = null;
|
|
13906
14028
|
// Drop anything typed (but not submitted) during the turn — echo was muted, so it's
|
|
@@ -14171,7 +14293,12 @@ async function main() {
|
|
|
14171
14293
|
// local mongodb is unreachable. Cloud mode failing to reach the hosted API means the
|
|
14172
14294
|
// computer has lost its internet connection. Two different messages, one per mode.
|
|
14173
14295
|
dbUnreachable = true;
|
|
14174
|
-
if (isLocalApiBase(apiBase)) {
|
|
14296
|
+
if (isLocalApiBase(apiBase) && (isNetworkError(err) || isTimeout(err))) {
|
|
14297
|
+
// THE API ITSELF IS NOT ANSWERING (GH #397) — MongoDB was not even asked. Blaming the
|
|
14298
|
+
// database here sent the reader to check mongod while it was fine; the message right after
|
|
14299
|
+
// this one names the API and starts it.
|
|
14300
|
+
console.error(dim("(the local API isn't answering — your saved model/key choice is unavailable)"));
|
|
14301
|
+
} else if (isLocalApiBase(apiBase)) {
|
|
14175
14302
|
console.error(dim("(cannot access local mongodb — your saved model/key choice is unavailable)"));
|
|
14176
14303
|
} else {
|
|
14177
14304
|
console.error(dim("(your computer has lost its internet connection — your saved model/key choice is unavailable)"));
|
|
@@ -14742,6 +14869,19 @@ async function main() {
|
|
|
14742
14869
|
);
|
|
14743
14870
|
} else {
|
|
14744
14871
|
const started = await tryStartLocalApi();
|
|
14872
|
+
// CARRY ON INSTEAD OF "run gu again" (GH #397). tryStartLocalApi waited for the API to
|
|
14873
|
+
// answer, so the only thing left is the startup this process already abandoned — and the
|
|
14874
|
+
// simplest correct way to redo it is to BE a fresh gu: execve keeps the pid, the terminal
|
|
14875
|
+
// and the arguments. Where execve is missing (Node < 22.15, Windows) the old sentence stays.
|
|
14876
|
+
// ONCE: an API that answers the health check and then dies would otherwise loop forever.
|
|
14877
|
+
if (started && !process.env.GU_API_REOPENED && typeof process.execve === "function" && process.platform !== "win32" && process.stdin.isTTY) {
|
|
14878
|
+
console.error(dim(" Started it — opening gu…\n"));
|
|
14879
|
+
try {
|
|
14880
|
+
process.execve(process.execPath, [process.execPath, ...process.execArgv, ...process.argv.slice(1)], { ...process.env, GU_API_REOPENED: "1" });
|
|
14881
|
+
} catch {
|
|
14882
|
+
/* not allowed here — fall through to the sentence below */
|
|
14883
|
+
}
|
|
14884
|
+
}
|
|
14745
14885
|
console.error(
|
|
14746
14886
|
started
|
|
14747
14887
|
? dim(" Started it — run `gu` again.\n")
|
|
@@ -14764,6 +14904,7 @@ async function main() {
|
|
|
14764
14904
|
}
|
|
14765
14905
|
process.exit(1);
|
|
14766
14906
|
}
|
|
14907
|
+
delete process.env.GU_API_REOPENED; // GH #397: started — the next start may re-open again
|
|
14767
14908
|
console.log(wrapGutter("Ask about this repo. Type / to see commands (Tab completes). Ctrl-C aborts a running turn.", dim) + "\n");
|
|
14768
14909
|
// #221: gu runs, but its own npm folder is not on PATH (npm skipped the postinstall that adds it).
|
|
14769
14910
|
try {
|
|
@@ -14875,6 +15016,7 @@ async function main() {
|
|
|
14875
15016
|
// not just detach. The worker sees the "cancelled" status and kills the Python
|
|
14876
15017
|
// agent; the poll loop then returns cleanly with the "(cancelled)" note.
|
|
14877
15018
|
cancelRequested = true;
|
|
15019
|
+
if (supportAfterTurn && barPinned()) paintBar(); // its row now says "Ctrl-C again" (GH #382)
|
|
14878
15020
|
const jobId = currentJobId;
|
|
14879
15021
|
// #307: while the question is still held (nothing printed yet), nothing goes to the
|
|
14880
15022
|
// transcript either — a quick change of mind leaves no trace.
|
|
@@ -14981,6 +15123,11 @@ async function main() {
|
|
|
14981
15123
|
// the turn ends; a queued line carries no Enter keypress, so its re-prompt overwrites
|
|
14982
15124
|
// in place (\r + refresh) rather than stacking ">> >> >> " on one row.
|
|
14983
15125
|
let lineRaw;
|
|
15126
|
+
// "? support" was clicked during the turn that just ended (GH #382) — however it ended.
|
|
15127
|
+
if (supportAfterTurn) {
|
|
15128
|
+
supportAfterTurn = false;
|
|
15129
|
+
_lineQueue.unshift("/support");
|
|
15130
|
+
}
|
|
14984
15131
|
mainPromptWaiting = true; // a finished background shell may start a recap turn now (#362)
|
|
14985
15132
|
for (;;) {
|
|
14986
15133
|
lineRaw = await nextLine();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tiens.nguyen/gu-cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.897",
|
|
4
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",
|
package/platform-tools.mjs
CHANGED
|
@@ -30,8 +30,15 @@ const run = (cmd, args, opts = {}) =>
|
|
|
30
30
|
|
|
31
31
|
/* ------------------------------------------------------------------ process listing ----- */
|
|
32
32
|
|
|
33
|
-
/**
|
|
34
|
-
|
|
33
|
+
/**
|
|
34
|
+
* The command that lists every process with its full command line.
|
|
35
|
+
*
|
|
36
|
+
* `ownUser` (GH #396): only THIS user's processes, on macOS and Linux. Another account on the same machine has its
|
|
37
|
+
* own HOME, worker.env and daemon: counting its gu daemon as "ours is running" meant a second account's daemon
|
|
38
|
+
* was never started (found on a Mac shared by two accounts: "start it with `gu start`"), and it is not ours to stop.
|
|
39
|
+
* Windows keeps the full list: a daemon started by a scheduled task runs in a different session than the terminal.
|
|
40
|
+
*/
|
|
41
|
+
export function processListCommand(platform = process.platform, { ownUser = false, uid = process.getuid?.() } = {}) {
|
|
35
42
|
return platform === "win32"
|
|
36
43
|
? {
|
|
37
44
|
// CIM over WMIC: wmic is deprecated and absent from newer Windows 11 images, and
|
|
@@ -43,7 +50,7 @@ export function processListCommand(platform = process.platform) {
|
|
|
43
50
|
"Get-CimInstance Win32_Process | ForEach-Object { \"$($_.ProcessId) $($_.CommandLine)\" }",
|
|
44
51
|
],
|
|
45
52
|
}
|
|
46
|
-
: { cmd: "ps", args: ["-A", "-o", "pid=,command="] };
|
|
53
|
+
: { cmd: "ps", args: [...(ownUser && Number.isInteger(uid) ? ["-U", String(uid)] : ["-A"]), "-o", "pid=,command="] };
|
|
47
54
|
}
|
|
48
55
|
|
|
49
56
|
/**
|
package/terminal-layout.mjs
CHANGED
|
@@ -434,6 +434,68 @@ export function promptBreakdownRows(b) {
|
|
|
434
434
|
}));
|
|
435
435
|
}
|
|
436
436
|
|
|
437
|
+
/**
|
|
438
|
+
* DID THE USER TYPE DURING THE TURN? (GH #382) — for the "(a turn is running — typing is not
|
|
439
|
+
* sent …)" notice. A printable key used to be enough, but the terminal also sends text nobody
|
|
440
|
+
* typed: a mouse report or a cursor-position reply already on its way when Enter started the
|
|
441
|
+
* turn. On Windows its ESC often comes alone (node gives it up after 500ms) and the rest arrives
|
|
442
|
+
* as ordinary characters — "[", "^[[", "[12;5R" — which showed the notice with no key pressed.
|
|
443
|
+
*
|
|
444
|
+
* A lone Esc, "[" or "^" starts a held run. It stays a reply while the characters fit a mouse
|
|
445
|
+
* report or cursor reply ("[", "<", "^", digits, ";") and ends silently on its "M", "m" or "R".
|
|
446
|
+
* Anything else means it was typing after all: `feed` → true. Enter after a held "[" too.
|
|
447
|
+
* The joined mouse shape never gets here (createMouseKeyWindow); the caller calls `reset()` for
|
|
448
|
+
* the keys that window takes, which includes the rest of a split report after its "[".
|
|
449
|
+
*/
|
|
450
|
+
export function createTurnTypingDetector({ maxRun = 32 } = {}) {
|
|
451
|
+
let active = false;
|
|
452
|
+
let held = "";
|
|
453
|
+
const reset = () => {
|
|
454
|
+
active = false;
|
|
455
|
+
held = "";
|
|
456
|
+
};
|
|
457
|
+
return {
|
|
458
|
+
/** → true when this key shows the user typed. Feed every key of the turn, in order. */
|
|
459
|
+
feed(ch, key) {
|
|
460
|
+
if (key?.name === "escape") {
|
|
461
|
+
active = true;
|
|
462
|
+
held = "";
|
|
463
|
+
return false;
|
|
464
|
+
}
|
|
465
|
+
if (key && (key.name === "return" || key.name === "enter")) {
|
|
466
|
+
const typed = active && held !== "";
|
|
467
|
+
reset();
|
|
468
|
+
return typed;
|
|
469
|
+
}
|
|
470
|
+
const printable =
|
|
471
|
+
typeof ch === "string" && ch.length > 0 && !/[\x00-\x1f\x7f]/.test(ch) && !key?.ctrl && !key?.meta;
|
|
472
|
+
if (!printable) {
|
|
473
|
+
reset();
|
|
474
|
+
return false;
|
|
475
|
+
}
|
|
476
|
+
if (!active) {
|
|
477
|
+
if (ch === "[" || ch === "^") {
|
|
478
|
+
active = true;
|
|
479
|
+
held = ch;
|
|
480
|
+
return false;
|
|
481
|
+
}
|
|
482
|
+
return true;
|
|
483
|
+
}
|
|
484
|
+
if (/^[MmR]$/.test(ch) && held.includes("[")) {
|
|
485
|
+
reset();
|
|
486
|
+
return false;
|
|
487
|
+
}
|
|
488
|
+
if (/^[[<^\d;]$/.test(ch) && held.length < maxRun && (held !== "" || ch === "[" || ch === "^")) {
|
|
489
|
+
held += ch;
|
|
490
|
+
return false;
|
|
491
|
+
}
|
|
492
|
+
reset();
|
|
493
|
+
return true;
|
|
494
|
+
},
|
|
495
|
+
reset,
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
437
499
|
/**
|
|
438
500
|
* A MOUSE REPORT IS NOT TYPING — the window in which keypress events belong to the mouse.
|
|
439
501
|
*
|
|
@@ -495,26 +557,50 @@ export function createMouseKeyWindow({ maxRun = 24 } = {}) {
|
|
|
495
557
|
* "^" is only held while the terminal is reporting the mouse (`plainLeads`), so typing them is
|
|
496
558
|
* not slowed down otherwise.
|
|
497
559
|
*/
|
|
498
|
-
const MOUSE_LEADS = ["\x1b[<", "^[[<", "[<"];
|
|
499
560
|
const MOUSE_BODY_DONE = /^(\d+;\d+;\d+[Mm])$/;
|
|
500
561
|
const MOUSE_BODY_PART = /^\d*(?:;\d*){0,2}$/;
|
|
562
|
+
// Windows "win32-input-mode" key records: ESC [ Vk ; Sc ; Uc ; Kd ; Cs ; Rc _ (six numbers).
|
|
563
|
+
const WIN32_BODY_DONE = /^\d+(?:;\d+){5}_$/;
|
|
564
|
+
const WIN32_BODY_PART = /^\d*(?:;\d*){0,5}$/;
|
|
565
|
+
/**
|
|
566
|
+
* What `text` (the held keys, ESC written as "\x1b") is so far. Leads: a real ESC, ESC spelled
|
|
567
|
+
* "^[", or no ESC at all. The last two only while the mouse is reported (`plainLeads`), and a
|
|
568
|
+
* bare "[" only for a MOUSE report: "[I" or "[1;2" are things people type.
|
|
569
|
+
* done + report "35;114;22M" a mouse report
|
|
570
|
+
* done + report null a terminal message with nothing to do: focus in/out ("I"/"O",
|
|
571
|
+
* sent when the window is minimised and restored — GH #365) or a
|
|
572
|
+
* win32 key record the console let through
|
|
573
|
+
*/
|
|
501
574
|
function mouseTextVerdict(text, plainLeads) {
|
|
502
|
-
for (const lead of
|
|
503
|
-
if (!plainLeads && lead !== "\x1b[
|
|
575
|
+
for (const lead of ["\x1b[", "^[[", "["]) {
|
|
576
|
+
if (!plainLeads && lead !== "\x1b[") continue;
|
|
504
577
|
if (lead.startsWith(text)) return { kind: "partial" };
|
|
505
578
|
if (!text.startsWith(lead)) continue;
|
|
506
579
|
const body = text.slice(lead.length);
|
|
507
|
-
if (
|
|
508
|
-
|
|
580
|
+
if (body.startsWith("<")) {
|
|
581
|
+
const m = body.slice(1);
|
|
582
|
+
if (MOUSE_BODY_DONE.test(m)) return { kind: "done", report: m };
|
|
583
|
+
if (m.length <= 20 && MOUSE_BODY_PART.test(m)) return { kind: "partial" };
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
if (lead === "[") {
|
|
587
|
+
if (body === "") return { kind: "partial" };
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
if (body === "I" || body === "O" || WIN32_BODY_DONE.test(body)) return { kind: "done", report: null };
|
|
591
|
+
if (body.length <= 40 && WIN32_BODY_PART.test(body)) return { kind: "partial" };
|
|
509
592
|
}
|
|
510
593
|
return { kind: "bad" };
|
|
511
594
|
}
|
|
512
595
|
export function createMouseReportReader() {
|
|
513
596
|
let held = null; // [str, key] pairs
|
|
597
|
+
// A CSI node could not name ("\x1b[I", or "\x1b[17;29" with the rest still to come) is held
|
|
598
|
+
// by its sequence; a named one (an arrow, F3, Ctrl+Up) is a key and never starts a report.
|
|
599
|
+
const unnamedCsi = (key) => key?.name === "undefined" && /^\x1b\[/.test(key?.sequence ?? "");
|
|
514
600
|
const textOf = (str, key) =>
|
|
515
|
-
key?.code === "[<" ? "\x1b[<" : key?.name === "escape" ? "\x1b" : typeof str === "string" ? str : "";
|
|
601
|
+
key?.code === "[<" ? "\x1b[<" : unnamedCsi(key) ? key.sequence : key?.name === "escape" ? "\x1b" : typeof str === "string" ? str : "";
|
|
516
602
|
const startsOne = (str, key, plainLeads) =>
|
|
517
|
-
key?.code === "[<" || key?.name === "escape" || (plainLeads && (str === "[" || str === "^"));
|
|
603
|
+
key?.code === "[<" || unnamedCsi(key) || key?.name === "escape" || (plainLeads && (str === "[" || str === "^"));
|
|
518
604
|
return {
|
|
519
605
|
holding: () => held !== null,
|
|
520
606
|
/**
|
|
@@ -533,6 +619,8 @@ export function createMouseReportReader() {
|
|
|
533
619
|
const v = mouseTextVerdict(textOf(str, key), plainLeads);
|
|
534
620
|
if (v.kind === "partial") return { action: "hold" };
|
|
535
621
|
held = null;
|
|
622
|
+
// Whole in one key ("\x1b[O"): node would drop it anyway, but it is ours to drop.
|
|
623
|
+
if (v.kind === "done") return { action: "consume", report: v.report };
|
|
536
624
|
return { action: "pass" };
|
|
537
625
|
}
|
|
538
626
|
held.push([str, key]);
|
|
@@ -564,7 +652,9 @@ export function createMouseReportReader() {
|
|
|
564
652
|
|
|
565
653
|
/** Last line of defence at submit: a report that got past every reader is not a question. */
|
|
566
654
|
export function stripLeakedMouseReports(line) {
|
|
567
|
-
return String(line)
|
|
655
|
+
return String(line)
|
|
656
|
+
.replace(/(?:\x1b|\^\[)?\[<\d+;\d+;\d+[Mm]/g, "")
|
|
657
|
+
.replace(/(?:\x1b|\^\[)\[(?:[IO]|\d+(?:;\d+){5}_)/g, "");
|
|
568
658
|
}
|
|
569
659
|
|
|
570
660
|
/**
|