@tiens.nguyen/gu-cli 1.0.896 → 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/agent-model-command.mjs +15 -0
- package/coding-provider-setup.mjs +1 -1
- package/daemon-control.mjs +2 -1
- package/doctor-check.mjs +63 -1
- package/gonext_agent_chat.py +4 -2
- package/gu-repl.mjs +165 -54
- package/package.json +1 -1
- package/platform-tools.mjs +10 -3
- package/terminal-layout.mjs +36 -8
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
|
// ---------------------------------------------------------------------------------------------
|
|
@@ -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
|
|
package/gu-repl.mjs
CHANGED
|
@@ -1436,7 +1436,7 @@ rl._ttyWrite = (str, key) => {
|
|
|
1436
1436
|
}
|
|
1437
1437
|
if (verdict.action === "consume") {
|
|
1438
1438
|
mouseClearTimer();
|
|
1439
|
-
applyMouseReport(verdict.report);
|
|
1439
|
+
if (verdict.report) applyMouseReport(verdict.report); // null: focus in/out, a win32 record
|
|
1440
1440
|
return;
|
|
1441
1441
|
}
|
|
1442
1442
|
if (verdict.action === "replay") {
|
|
@@ -6196,7 +6196,11 @@ async function runModelDoctor() {
|
|
|
6196
6196
|
return;
|
|
6197
6197
|
}
|
|
6198
6198
|
console.log(dim(" Asking each model to answer — the real request, not just a ping."));
|
|
6199
|
-
|
|
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);
|
|
6200
6204
|
const results = [];
|
|
6201
6205
|
for (const r of roles) {
|
|
6202
6206
|
// One at a time, and SAID one at a time: these share a GPU, so a check can take a while and
|
|
@@ -6213,13 +6217,8 @@ async function runModelDoctor() {
|
|
|
6213
6217
|
console.log(GUTTER + (res.status === "ok" ? dim(l) : res.status === "unset" ? dim(l) : yellow(l)));
|
|
6214
6218
|
}
|
|
6215
6219
|
}
|
|
6216
|
-
const
|
|
6217
|
-
console.log(
|
|
6218
|
-
"\n" + GUTTER +
|
|
6219
|
-
(bad.length === 0
|
|
6220
|
-
? accent("✓ every configured model answered.")
|
|
6221
|
-
: red(`✗ ${bad.length} of ${results.length} did not answer.`))
|
|
6222
|
-
);
|
|
6220
|
+
const verdict0 = dc.doctorSummary(results);
|
|
6221
|
+
console.log("\n" + GUTTER + (verdict0.tone === "ok" ? accent : verdict0.tone === "bad" ? red : yellow)(verdict0.text));
|
|
6223
6222
|
// THE GU SERVICE TOO (#413): every model can answer and a coding turn still fail, because its
|
|
6224
6223
|
// instructions come from the gu service — reported on a Windows VM where only "hi" worked.
|
|
6225
6224
|
const sc = await import("./service-check.mjs");
|
|
@@ -6292,10 +6291,16 @@ async function doctorFetch(url, init) {
|
|
|
6292
6291
|
* Returns [] when there is nothing to offer, or null when the user cancelled — which the caller
|
|
6293
6292
|
* must treat as "abandon", not as "offer none", or Escape would silently save something.
|
|
6294
6293
|
*/
|
|
6295
|
-
async function pickExtraModels(
|
|
6296
|
-
|
|
6297
|
-
|
|
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.
|
|
6298
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}`));
|
|
6299
6304
|
const ticks = await pickManyFromList(
|
|
6300
6305
|
rest,
|
|
6301
6306
|
rest.map((m, i) => (already.has(m) ? i : -1)).filter((i) => i >= 0),
|
|
@@ -6333,11 +6338,11 @@ async function chooseCodingModelFrom(ps, all, question, current, { back = false
|
|
|
6333
6338
|
// Opens on the CURRENT model where there is one — a long list that opens at the top tells the
|
|
6334
6339
|
// reader nothing about which one they are already using.
|
|
6335
6340
|
const at = typeof current === "string" ? list.indexOf(current) : Number(current) || 0;
|
|
6336
|
-
|
|
6337
|
-
|
|
6338
|
-
|
|
6339
|
-
);
|
|
6340
|
-
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 });
|
|
6341
6346
|
if (idx === PICK_BACK) return BACK;
|
|
6342
6347
|
if (idx < 0) return null;
|
|
6343
6348
|
if (idx === list.length) {
|
|
@@ -6349,6 +6354,7 @@ async function chooseCodingModelFrom(ps, all, question, current, { back = false
|
|
|
6349
6354
|
showingAll = true;
|
|
6350
6355
|
continue;
|
|
6351
6356
|
}
|
|
6357
|
+
console.log(dim(` → ${list[idx]}`));
|
|
6352
6358
|
return list[idx];
|
|
6353
6359
|
}
|
|
6354
6360
|
}
|
|
@@ -6369,14 +6375,18 @@ async function chooseCodingModelFrom(ps, all, question, current, { back = false
|
|
|
6369
6375
|
async function chooseFromModelList(all, question, current = 0, { back = false } = {}) {
|
|
6370
6376
|
const rows = [...all, "type a model name…"];
|
|
6371
6377
|
const at = typeof current === "string" ? all.indexOf(current) : Number(current) || 0;
|
|
6372
|
-
|
|
6373
|
-
|
|
6378
|
+
const idx = await pickFromList(rows, at >= 0 ? at : 0, {
|
|
6379
|
+
back,
|
|
6380
|
+
header: () => [dim(` ${question} (${PICK_HINT})`)],
|
|
6381
|
+
headerRows: 1,
|
|
6382
|
+
});
|
|
6374
6383
|
if (idx === PICK_BACK) return BACK;
|
|
6375
6384
|
if (idx < 0) return null;
|
|
6376
6385
|
if (idx === all.length) {
|
|
6377
6386
|
const typed = (await ask(" model name: ")).trim();
|
|
6378
6387
|
return typed || null;
|
|
6379
6388
|
}
|
|
6389
|
+
console.log(dim(` → ${all[idx]}`));
|
|
6380
6390
|
return all[idx];
|
|
6381
6391
|
}
|
|
6382
6392
|
|
|
@@ -6405,8 +6415,13 @@ async function manageCodingProviders({ back = false } = {}) {
|
|
|
6405
6415
|
);
|
|
6406
6416
|
const ADD = labels.length;
|
|
6407
6417
|
labels.push(actionRow("add another provider — Claude API, OpenAI, Kimi…"));
|
|
6408
|
-
|
|
6409
|
-
|
|
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
|
+
});
|
|
6410
6425
|
if (idx === PICK_BACK) return BACK;
|
|
6411
6426
|
if (idx < 0) {
|
|
6412
6427
|
console.log(dim(" (cancelled)\n"));
|
|
@@ -6420,11 +6435,10 @@ async function manageCodingProviders({ back = false } = {}) {
|
|
|
6420
6435
|
|
|
6421
6436
|
const p = providers[idx];
|
|
6422
6437
|
for (;;) {
|
|
6423
|
-
console.log("\n" + GUTTER + dim(`${p.host} · ${p.model || "no model set"}`));
|
|
6424
6438
|
const action = await pickFromList(
|
|
6425
6439
|
[dim("keep it"), "change the model…", "change the URL…", "remove this provider…"],
|
|
6426
6440
|
1,
|
|
6427
|
-
{ back: true }
|
|
6441
|
+
{ back: true, header: () => [GUTTER + dim(`${p.host} · ${p.model || "no model set"}`)], headerRows: 1 }
|
|
6428
6442
|
);
|
|
6429
6443
|
if (action === PICK_BACK) break; // → the provider list
|
|
6430
6444
|
if (action <= 0) {
|
|
@@ -6548,7 +6562,7 @@ async function changeProviderModel(ps, rows, p) {
|
|
|
6548
6562
|
await syncActiveCoderWhere();
|
|
6549
6563
|
console.log(GUTTER + dim("this folder's pick named the old model — cleared it."));
|
|
6550
6564
|
}
|
|
6551
|
-
console.log(accent(`
|
|
6565
|
+
console.log(accent(` ✓ ${p.host} now uses ${model}`) + dim(" · /model switches between its models\n"));
|
|
6552
6566
|
}
|
|
6553
6567
|
|
|
6554
6568
|
/** Point a provider at a different URL, keeping its id so saved picks still resolve. */
|
|
@@ -6639,6 +6653,18 @@ async function removeCodingProvider(ps, rows, p) {
|
|
|
6639
6653
|
);
|
|
6640
6654
|
console.log(GUTTER + dim(" they will be deleted too; a key with no provider can never be used again.\n"));
|
|
6641
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
|
+
}
|
|
6642
6668
|
const yes = await askYesNo(
|
|
6643
6669
|
`Remove ${p.host} (${p.model || "no model set"})? This cannot be undone.`,
|
|
6644
6670
|
false
|
|
@@ -6650,6 +6676,7 @@ async function removeCodingProvider(ps, rows, p) {
|
|
|
6650
6676
|
const saved = await withPendingStatus("removing", () =>
|
|
6651
6677
|
md.saveWorkerSettings(apiBase, workerKey, {
|
|
6652
6678
|
agentCodingBackends: ps.removeProvider(rows, p.id),
|
|
6679
|
+
...(agentUses ? { agentModelUrl: "", agentModelName: "", agentModel: "" } : {}),
|
|
6653
6680
|
})
|
|
6654
6681
|
);
|
|
6655
6682
|
if (!saved) {
|
|
@@ -6666,6 +6693,7 @@ async function removeCodingProvider(ps, rows, p) {
|
|
|
6666
6693
|
sessionCodingKeyId = "";
|
|
6667
6694
|
await saveSessionCodingKeyId(resolve(process.cwd()));
|
|
6668
6695
|
}
|
|
6696
|
+
if (k.id === sessionAgentKeyId) await saveFolderKeyPick("agent", "");
|
|
6669
6697
|
}
|
|
6670
6698
|
// This folder may have been pointed at the removed provider.
|
|
6671
6699
|
if (sessionCodingUrl === p.url || sessionCodingModel.startsWith(`${p.id}::`)) {
|
|
@@ -6679,8 +6707,21 @@ async function removeCodingProvider(ps, rows, p) {
|
|
|
6679
6707
|
console.log(
|
|
6680
6708
|
accent(` ✓ removed ${p.host}`) +
|
|
6681
6709
|
(stranded.length ? dim(` · ${deleted} key${deleted === 1 ? "" : "s"} deleted`) : "") +
|
|
6710
|
+
(agentUses ? dim(" · agent model unset") : "") +
|
|
6682
6711
|
"\n"
|
|
6683
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();
|
|
6684
6725
|
}
|
|
6685
6726
|
|
|
6686
6727
|
/**
|
|
@@ -6736,7 +6777,8 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
|
|
|
6736
6777
|
const providerPicker = !presetUrl && dialect === "openai";
|
|
6737
6778
|
const ec = role === "embed" ? await import("./embed-model-command.mjs") : null;
|
|
6738
6779
|
let target, host, found, need, key, keyName, typedKey, models, model;
|
|
6739
|
-
|
|
6780
|
+
let lastPick = 0; // GH #397: back from the key re-opens the list ON the provider just chosen
|
|
6781
|
+
providerLoop: for (;;) {
|
|
6740
6782
|
// --- 1. the server --------------------------------------------------------------------
|
|
6741
6783
|
target = null;
|
|
6742
6784
|
// The dialect the URL prompt reads the address in: the caller's, or Ollama's when that row was picked.
|
|
@@ -6754,22 +6796,31 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
|
|
|
6754
6796
|
const presets = ps.providerPresets(role);
|
|
6755
6797
|
// #244: an Ollama on this machine or the network is a row here, like the hosted ones — the
|
|
6756
6798
|
// menus no longer have a separate "use an Ollama server…" entrance. It asks for the address.
|
|
6757
|
-
|
|
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;
|
|
6758
6803
|
const rowsShown = [
|
|
6759
|
-
...presets.map((p) => `${p.name}${dim(` · ${p.url}`)}`),
|
|
6760
6804
|
`Ollama — this machine or your network…${dim(" · e.g. 127.0.0.1:11434")}`,
|
|
6805
|
+
...presets.map((p) => `${p.name}${dim(` · ${p.url}`)}`),
|
|
6761
6806
|
"another URL…",
|
|
6762
6807
|
];
|
|
6763
6808
|
// A preset names its own dialect (Ollama cloud is "ollama"); the rest are OpenAI-compatible.
|
|
6764
|
-
|
|
6765
|
-
|
|
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 });
|
|
6766
6812
|
if (pi === PICK_BACK) return BACK;
|
|
6767
6813
|
if (pi < 0) {
|
|
6768
6814
|
console.log(dim(" (nothing saved)\n"));
|
|
6769
6815
|
return;
|
|
6770
6816
|
}
|
|
6771
|
-
|
|
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);
|
|
6772
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"}`));
|
|
6773
6824
|
}
|
|
6774
6825
|
if (!target && !presetUrl) {
|
|
6775
6826
|
const dflt = as.defaultServerUrl(role, askDialect, settings, existing);
|
|
@@ -6835,8 +6886,19 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
|
|
|
6835
6886
|
if (key) console.log(GUTTER + dim(`using the key "${inUse.name}" already saved for ${host}.`));
|
|
6836
6887
|
}
|
|
6837
6888
|
}
|
|
6838
|
-
|
|
6839
|
-
|
|
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
|
+
}
|
|
6840
6902
|
key = t.secret;
|
|
6841
6903
|
keyName = t.name;
|
|
6842
6904
|
typedKey = Boolean(t.secret);
|
|
@@ -6844,7 +6906,9 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
|
|
|
6844
6906
|
};
|
|
6845
6907
|
if (!key && (need !== "none" || newKey)) {
|
|
6846
6908
|
if (need === "required") console.log(GUTTER + dim(`${host} needs an API key.`));
|
|
6847
|
-
|
|
6909
|
+
const got = await askKey();
|
|
6910
|
+
if (got === BACK_TO_PROVIDERS) continue providerLoop;
|
|
6911
|
+
if (!got) return;
|
|
6848
6912
|
}
|
|
6849
6913
|
|
|
6850
6914
|
// --- 3. its models, listed with that key ----------------------------------------------
|
|
@@ -6861,10 +6925,18 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
|
|
|
6861
6925
|
}
|
|
6862
6926
|
});
|
|
6863
6927
|
let probe = await list();
|
|
6864
|
-
//
|
|
6865
|
-
|
|
6866
|
-
|
|
6867
|
-
|
|
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;
|
|
6868
6940
|
probe = await list();
|
|
6869
6941
|
}
|
|
6870
6942
|
if (probe.status || probe.err) {
|
|
@@ -6941,6 +7013,7 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
|
|
|
6941
7013
|
}
|
|
6942
7014
|
|
|
6943
7015
|
// --- 5. save: the server, its key, then the role --------------------------------------
|
|
7016
|
+
let alsoInModel = false;
|
|
6944
7017
|
let rowId = found?.id ?? "";
|
|
6945
7018
|
let rows = existing;
|
|
6946
7019
|
// A NEW ROW ONLY WHEN SOMETHING NEEDS ONE: the coder is picked through its row, and a key is
|
|
@@ -6967,7 +7040,8 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
|
|
|
6967
7040
|
console.log(GUTTER + dim("if this machine's local API is old, restart it with `gu api restart`.\n"));
|
|
6968
7041
|
return;
|
|
6969
7042
|
}
|
|
6970
|
-
|
|
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;
|
|
6971
7045
|
}
|
|
6972
7046
|
if (typedKey) {
|
|
6973
7047
|
const id = await withPendingStatus("saving the key", () => addCodingKey(keyName, key, rowId));
|
|
@@ -6980,14 +7054,15 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
|
|
|
6980
7054
|
// the stored one was refused: it is the server's key from now on, for every role and folder.
|
|
6981
7055
|
if (found) await setServerDefaultKey(rowId, id);
|
|
6982
7056
|
}
|
|
6983
|
-
const keyLine = key ? dim(`
|
|
7057
|
+
const keyLine = key ? dim(` API key ${keyName || "saved"} ····${key.slice(-4)}`) : "";
|
|
6984
7058
|
|
|
6985
7059
|
if (role === "agent") {
|
|
6986
7060
|
// A MACHINE SETTING, not a folder pick: the agent is what gu talks to in every folder. Saved
|
|
6987
7061
|
// as URL + NAME, the pair app.ts matches back to the server row to find the key.
|
|
6988
|
-
|
|
6989
|
-
|
|
6990
|
-
|
|
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 */
|
|
6991
7066
|
} else if (rows !== existing || typedKey) {
|
|
6992
7067
|
// The SERVER landed — say so, or the user re-enters a key that is already stored.
|
|
6993
7068
|
console.log(yellow(` ${host} was saved, but the agent model was not pointed at it.`));
|
|
@@ -7181,11 +7256,13 @@ async function chooseModel() {
|
|
|
7181
7256
|
// choices while still occupying its URL, so sending this branch straight to the ADD flow
|
|
7182
7257
|
// would meet "already configured here" and dead-end. The manager fixes such a row, and falls
|
|
7183
7258
|
// through to add when there is genuinely nothing there.
|
|
7184
|
-
|
|
7259
|
+
// As the picker's header (GH #397), so "← back" re-opening this menu does not stack it.
|
|
7185
7260
|
const go = await pickFromList(
|
|
7186
7261
|
[dim("leave it"), "add or change a provider — Claude API, OpenAI, Kimi…"],
|
|
7187
|
-
1
|
|
7262
|
+
1,
|
|
7263
|
+
{ header: () => [dim(" No coding model configured.")], headerRows: 1 }
|
|
7188
7264
|
);
|
|
7265
|
+
if (go !== 1) console.log(dim(" No coding model configured."));
|
|
7189
7266
|
if (go === 1 && (await manageCodingProviders({ back: true })) === BACK) return chooseModel();
|
|
7190
7267
|
if (go !== 1) console.log("");
|
|
7191
7268
|
return;
|
|
@@ -7239,8 +7316,11 @@ async function chooseModel() {
|
|
|
7239
7316
|
// enough in name to have already been mixed up. The fix is to say which one THIS is — not to
|
|
7240
7317
|
// print the other two here: the agent model and the embedder are independent settings with
|
|
7241
7318
|
// their own commands, and listing them together implies a relationship that does not exist.
|
|
7242
|
-
|
|
7243
|
-
|
|
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
|
+
});
|
|
7244
7324
|
if (idx < 0) {
|
|
7245
7325
|
console.log(dim(" (cancelled)\n"));
|
|
7246
7326
|
return;
|
|
@@ -9164,7 +9244,14 @@ async function chooseEmbedModel() {
|
|
|
9164
9244
|
if (keyRequired) labels.push(actionRow(`API keys for ${host || currentUrl} — switch, add or remove…`));
|
|
9165
9245
|
const ADD = labels.length;
|
|
9166
9246
|
labels.push(actionRow("add a provider — OpenAI, Ollama…"));
|
|
9167
|
-
|
|
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";
|
|
9168
9255
|
const hint = `(${PICK_ARROWS}):`;
|
|
9169
9256
|
const cols = Math.max(20, process.stdout.columns || 80);
|
|
9170
9257
|
console.log(dim(head.length + 2 + hint.length < cols ? `${head} ${hint}` : `${head}\n ${hint}`));
|
|
@@ -9218,7 +9305,7 @@ async function chooseEmbedModel() {
|
|
|
9218
9305
|
}
|
|
9219
9306
|
|
|
9220
9307
|
/** Save the agent model on this machine: the URL the API sends turns to, and the model name. */
|
|
9221
|
-
async function saveAgentModel(agentUrl, model, host) {
|
|
9308
|
+
async function saveAgentModel(agentUrl, model, host, extra = []) {
|
|
9222
9309
|
const md = await import("./model-doctor.mjs");
|
|
9223
9310
|
const ok = await withPendingStatus("pointing the agent model at it", () =>
|
|
9224
9311
|
md.saveWorkerSettings(apiBase, workerKey, { agentModelUrl: agentUrl, agentModelName: model, agentModel: model })
|
|
@@ -9229,6 +9316,7 @@ async function saveAgentModel(agentUrl, model, host) {
|
|
|
9229
9316
|
return false;
|
|
9230
9317
|
}
|
|
9231
9318
|
console.log(accent(` ✓ agent model → ${model}`) + dim(` · ${host}`));
|
|
9319
|
+
for (const l of extra) console.log(l);
|
|
9232
9320
|
console.log(dim(" takes effect on your next question.\n"));
|
|
9233
9321
|
return true;
|
|
9234
9322
|
}
|
|
@@ -9237,7 +9325,9 @@ async function saveAgentModel(agentUrl, model, host) {
|
|
|
9237
9325
|
* Ask for a key's NAME — and refuse the key itself as a name: names are printed wherever keys are
|
|
9238
9326
|
* listed, so a pasted secret there is a secret on screen (reported: "API key sk-E8Iv… ····3E66").
|
|
9239
9327
|
*/
|
|
9240
|
-
|
|
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): ") {
|
|
9241
9331
|
const am = await import("./agent-model-command.mjs");
|
|
9242
9332
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
9243
9333
|
const name = (await ask(prompt)).trim();
|
|
@@ -9258,13 +9348,15 @@ async function askKeyName(prompt = " key name: ") {
|
|
|
9258
9348
|
* "no key" (a local vLLM answers anonymously). `host` names the server in the prompt and decides
|
|
9259
9349
|
* the shape check's locality: a local server accepts any string, so "abc" is not questioned there.
|
|
9260
9350
|
*/
|
|
9261
|
-
async function askServerKey({ host = "", optional = false } = {}) {
|
|
9351
|
+
async function askServerKey({ host = "", optional = false, escBack = false, name: knownName = null } = {}) {
|
|
9262
9352
|
const ps = await import("./coding-provider-setup.mjs");
|
|
9263
9353
|
// Esc is said here (#249): the prompt that follows is bare text, with nothing on it saying a
|
|
9264
9354
|
// way out exists — Esc cancels the whole add, nothing saved. (The line explaining what a key
|
|
9265
9355
|
// name is for was removed on request.)
|
|
9266
|
-
|
|
9267
|
-
|
|
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);
|
|
9268
9360
|
console.log(GUTTER + dim(SECRET_KEY_HINT));
|
|
9269
9361
|
const secret = (await askSecret(` API key${host ? ` for ${host}` : ""}${name ? ` (${name})` : ""}: `)).trim();
|
|
9270
9362
|
if (!secret) {
|
|
@@ -9499,7 +9591,7 @@ async function agentModelOnServer({ back = false, start = "menu" } = {}) {
|
|
|
9499
9591
|
const options = am.agentRecoveryOptions({ keyProblem, host: listHost });
|
|
9500
9592
|
const go = await pickFromList(
|
|
9501
9593
|
options.map((o, i) => (i === 0 ? dim(o.label) : o.label)),
|
|
9502
|
-
|
|
9594
|
+
am.agentRecoveryDefault(options, { keyProblem, configured: Boolean(url || current) }),
|
|
9503
9595
|
{ back }
|
|
9504
9596
|
);
|
|
9505
9597
|
if (go === PICK_BACK) return BACK;
|
|
@@ -14201,7 +14293,12 @@ async function main() {
|
|
|
14201
14293
|
// local mongodb is unreachable. Cloud mode failing to reach the hosted API means the
|
|
14202
14294
|
// computer has lost its internet connection. Two different messages, one per mode.
|
|
14203
14295
|
dbUnreachable = true;
|
|
14204
|
-
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)) {
|
|
14205
14302
|
console.error(dim("(cannot access local mongodb — your saved model/key choice is unavailable)"));
|
|
14206
14303
|
} else {
|
|
14207
14304
|
console.error(dim("(your computer has lost its internet connection — your saved model/key choice is unavailable)"));
|
|
@@ -14772,6 +14869,19 @@ async function main() {
|
|
|
14772
14869
|
);
|
|
14773
14870
|
} else {
|
|
14774
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
|
+
}
|
|
14775
14885
|
console.error(
|
|
14776
14886
|
started
|
|
14777
14887
|
? dim(" Started it — run `gu` again.\n")
|
|
@@ -14794,6 +14904,7 @@ async function main() {
|
|
|
14794
14904
|
}
|
|
14795
14905
|
process.exit(1);
|
|
14796
14906
|
}
|
|
14907
|
+
delete process.env.GU_API_REOPENED; // GH #397: started — the next start may re-open again
|
|
14797
14908
|
console.log(wrapGutter("Ask about this repo. Type / to see commands (Tab completes). Ctrl-C aborts a running turn.", dim) + "\n");
|
|
14798
14909
|
// #221: gu runs, but its own npm folder is not on PATH (npm skipped the postinstall that adds it).
|
|
14799
14910
|
try {
|
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
|
@@ -557,26 +557,50 @@ export function createMouseKeyWindow({ maxRun = 24 } = {}) {
|
|
|
557
557
|
* "^" is only held while the terminal is reporting the mouse (`plainLeads`), so typing them is
|
|
558
558
|
* not slowed down otherwise.
|
|
559
559
|
*/
|
|
560
|
-
const MOUSE_LEADS = ["\x1b[<", "^[[<", "[<"];
|
|
561
560
|
const MOUSE_BODY_DONE = /^(\d+;\d+;\d+[Mm])$/;
|
|
562
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
|
+
*/
|
|
563
574
|
function mouseTextVerdict(text, plainLeads) {
|
|
564
|
-
for (const lead of
|
|
565
|
-
if (!plainLeads && lead !== "\x1b[
|
|
575
|
+
for (const lead of ["\x1b[", "^[[", "["]) {
|
|
576
|
+
if (!plainLeads && lead !== "\x1b[") continue;
|
|
566
577
|
if (lead.startsWith(text)) return { kind: "partial" };
|
|
567
578
|
if (!text.startsWith(lead)) continue;
|
|
568
579
|
const body = text.slice(lead.length);
|
|
569
|
-
if (
|
|
570
|
-
|
|
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" };
|
|
571
592
|
}
|
|
572
593
|
return { kind: "bad" };
|
|
573
594
|
}
|
|
574
595
|
export function createMouseReportReader() {
|
|
575
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 ?? "");
|
|
576
600
|
const textOf = (str, key) =>
|
|
577
|
-
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 : "";
|
|
578
602
|
const startsOne = (str, key, plainLeads) =>
|
|
579
|
-
key?.code === "[<" || key?.name === "escape" || (plainLeads && (str === "[" || str === "^"));
|
|
603
|
+
key?.code === "[<" || unnamedCsi(key) || key?.name === "escape" || (plainLeads && (str === "[" || str === "^"));
|
|
580
604
|
return {
|
|
581
605
|
holding: () => held !== null,
|
|
582
606
|
/**
|
|
@@ -595,6 +619,8 @@ export function createMouseReportReader() {
|
|
|
595
619
|
const v = mouseTextVerdict(textOf(str, key), plainLeads);
|
|
596
620
|
if (v.kind === "partial") return { action: "hold" };
|
|
597
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 };
|
|
598
624
|
return { action: "pass" };
|
|
599
625
|
}
|
|
600
626
|
held.push([str, key]);
|
|
@@ -626,7 +652,9 @@ export function createMouseReportReader() {
|
|
|
626
652
|
|
|
627
653
|
/** Last line of defence at submit: a report that got past every reader is not a question. */
|
|
628
654
|
export function stripLeakedMouseReports(line) {
|
|
629
|
-
return String(line)
|
|
655
|
+
return String(line)
|
|
656
|
+
.replace(/(?:\x1b|\^\[)?\[<\d+;\d+;\d+[Mm]/g, "")
|
|
657
|
+
.replace(/(?:\x1b|\^\[)\[(?:[IO]|\d+(?:;\d+){5}_)/g, "");
|
|
630
658
|
}
|
|
631
659
|
|
|
632
660
|
/**
|