@tiens.nguyen/gu-cli 1.0.767 → 1.0.769
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/client-expert-api.mjs +89 -8
- package/client-expert-run.mjs +104 -12
- package/gonext_agent_chat.py +6 -0
- package/gu-cli.mjs +13 -1
- package/gu-repl.mjs +138 -22
- package/model-setup-ui.mjs +114 -0
- package/ollama-setup.mjs +128 -23
- package/package.json +4 -1
- package/setup-prompts.mjs +82 -0
- package/step-tokens.mjs +55 -0
- package/terminal-layout.mjs +31 -1
package/gu-repl.mjs
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
* Commands: /exit /model /new /help Ctrl-C stops following the current run.
|
|
25
25
|
*/
|
|
26
26
|
import { readFile, mkdir, writeFile, unlink, rename } from "node:fs/promises";
|
|
27
|
+
import { readStepTokens } from "./step-tokens.mjs";
|
|
27
28
|
import { existsSync, statSync, readFileSync, writeFileSync, readdirSync, openSync, readSync, closeSync } from "node:fs";
|
|
28
29
|
import { spawn } from "node:child_process";
|
|
29
30
|
// FIRST, before anything that could itself fail on an old runtime: npm only WARNS about
|
|
@@ -95,6 +96,7 @@ import {
|
|
|
95
96
|
physicalRows,
|
|
96
97
|
inputPhysicalRows,
|
|
97
98
|
buildFooterRows,
|
|
99
|
+
pasteHint,
|
|
98
100
|
footerDrawSeq,
|
|
99
101
|
footerEraseSeq,
|
|
100
102
|
clearBlockSeq,
|
|
@@ -631,6 +633,10 @@ function reloadWorkerEnv() {
|
|
|
631
633
|
// first-run branch also runs one — without this the same walkthrough printed twice in a single
|
|
632
634
|
// run, MongoDB instructions and all. Reported live on Windows 11.
|
|
633
635
|
let clientExpertRanThisProcess = false;
|
|
636
|
+
// #301: setup chose "Load a saved preset" — main() opens /load-presets before the first prompt.
|
|
637
|
+
// Declared HERE, above the first-run block: that block runs at module top level, before any
|
|
638
|
+
// declaration further down exists.
|
|
639
|
+
let openPresetsAtStart = false;
|
|
634
640
|
|
|
635
641
|
if (!workerKey || !apiBase) {
|
|
636
642
|
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
@@ -727,7 +733,12 @@ if (!workerKey || !apiBase) {
|
|
|
727
733
|
await saveState({ ...(await loadState()), chosenMode: mode0 ?? "client" });
|
|
728
734
|
if (mode0 === "client-expert") {
|
|
729
735
|
clientExpertRanThisProcess = true;
|
|
730
|
-
|
|
736
|
+
// #301/#300: Esc-aware and masked readers on the same rl0 — the model questions offer
|
|
737
|
+
// Esc, and a key or a password must not echo.
|
|
738
|
+
const { setupPrompts } = await import("./setup-prompts.mjs");
|
|
739
|
+
const sp = setupPrompts(rl0, { style: accent });
|
|
740
|
+
const setup = await runClientExpertSetup({ ask: ask0, askStep: sp.askStep, askSecret: sp.askSecret });
|
|
741
|
+
if (setup.loadPreset) openPresetsAtStart = true;
|
|
731
742
|
// STOP when setup cannot go further.
|
|
732
743
|
//
|
|
733
744
|
// This used to fall through to the hosted sign-in, on the theory that "this machine
|
|
@@ -1943,9 +1954,12 @@ let lastPromptBreakdown = null;
|
|
|
1943
1954
|
* never leads it, so the screen never claims output that has not been produced.
|
|
1944
1955
|
*/
|
|
1945
1956
|
let shownOutputTokens = 0;
|
|
1946
|
-
// The same for INPUT (#53)
|
|
1947
|
-
// the "↓ N" output figure, so the thinking line reads both directions the way the bar does.
|
|
1957
|
+
// The same for INPUT (#53), shown as "↑ N" before the "↓ N" output figure.
|
|
1948
1958
|
let shownInputTokens = 0;
|
|
1959
|
+
// Task #298: both figures are THIS STEP's, not the turn's (the bar has those). Which step the
|
|
1960
|
+
// shown values belong to, so a new step starts its count from zero instead of easing on from
|
|
1961
|
+
// the previous step's number.
|
|
1962
|
+
let shownStepNo = 0;
|
|
1949
1963
|
// Seconds from Enter to the first full prompt reaching the coding model (prompt assembly +
|
|
1950
1964
|
// retrieval). Shown by /tokens; 0 until a turn has run.
|
|
1951
1965
|
let lastPromptAssemblySeconds = 0;
|
|
@@ -2372,6 +2386,9 @@ const footerRows = () =>
|
|
|
2372
2386
|
identity: identityLineText(),
|
|
2373
2387
|
token: tokenLineText(),
|
|
2374
2388
|
mode: modeLineText(),
|
|
2389
|
+
// How to paste here (issue #67): the platform's text key, plus gu's image key when the coder
|
|
2390
|
+
// can take an image.
|
|
2391
|
+
modeHint: pasteHint({ images: coderTakesImages() }),
|
|
2375
2392
|
// Its own row (#54), sized to the window so the fix it names is never the part cut off.
|
|
2376
2393
|
notice: imageNotice
|
|
2377
2394
|
? orange("⚠ " + fitImageNotice(imageNotice, (process.stdout.columns || 80) - visibleLen(GUTTER) - 1))
|
|
@@ -5227,15 +5244,35 @@ async function reconfigureOllamaServer() {
|
|
|
5227
5244
|
|
|
5228
5245
|
/** Load a box's models THROUGH THE PROXY (#160) — a bare fetch is what fails on a restricted
|
|
5229
5246
|
* network, and it fails as "no models", which is the wrong story entirely. */
|
|
5230
|
-
const load = async (n) => {
|
|
5247
|
+
const load = async (n, key = "") => {
|
|
5231
5248
|
const res = await withPendingStatus(`loading models from ${n.host}`, () =>
|
|
5232
|
-
proxyFetch(os.tagsUrlFor(n), {
|
|
5249
|
+
proxyFetch(os.tagsUrlFor(n), {
|
|
5250
|
+
signal: AbortSignal.timeout(8000),
|
|
5251
|
+
// #301: the key typed for this server; absent → the request is what it always was.
|
|
5252
|
+
...(key ? { headers: { Authorization: `Bearer ${key}` } } : {}),
|
|
5253
|
+
})
|
|
5233
5254
|
);
|
|
5234
5255
|
// The status is kept: a 404 here means "answered, but not an Ollama" (#43), not "unreachable".
|
|
5235
5256
|
if (!res.ok) throw Object.assign(new Error(`HTTP ${res.status}`), { httpStatus: res.status });
|
|
5236
5257
|
return am.parseOllamaTags(await res.json().catch(() => null));
|
|
5237
5258
|
};
|
|
5238
5259
|
|
|
5260
|
+
// #301: the way out, said under every server question (full once, short after), and the keys
|
|
5261
|
+
// typed for servers, by host — saved against their rows once the settings are written.
|
|
5262
|
+
const ui = await import("./model-setup-ui.mjs");
|
|
5263
|
+
const hint = ui.createHinter();
|
|
5264
|
+
const typedKeys = new Map();
|
|
5265
|
+
const askKeyFor = async (host) => {
|
|
5266
|
+
if (typedKeys.has(host)) return typedKeys.get(host); // same server for both roles → asked once
|
|
5267
|
+
console.log(dim(` ${hint()}`));
|
|
5268
|
+
// askSecret: masked, kept out of history; Esc throws PromptCancelled → /model is cancelled
|
|
5269
|
+
// with nothing changed, which is what "skip all" means here.
|
|
5270
|
+
const k = (await askSecret(` API key for ${host} (Enter for none): `)).trim();
|
|
5271
|
+
typedKeys.set(host, k);
|
|
5272
|
+
console.log(GUTTER + dim(ui.keyConfirmation(host, k)));
|
|
5273
|
+
return k;
|
|
5274
|
+
};
|
|
5275
|
+
|
|
5239
5276
|
/** Ask for one server and load it. Returns {normalized, models} or null (declined/failed). */
|
|
5240
5277
|
const askServer = async (label, dflt, { require = false, short = "server", role = "coder" } = {}) => {
|
|
5241
5278
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
@@ -5247,13 +5284,15 @@ async function reconfigureOllamaServer() {
|
|
|
5247
5284
|
// — normalizeOllamaUrl adds it — and displayUrl round-trips, so what is shown can be fed
|
|
5248
5285
|
// straight back as the default.
|
|
5249
5286
|
console.log(dim(` ${label} — Enter keeps ${os.displayUrl(dflt)}`));
|
|
5287
|
+
console.log(dim(` ${hint()}`));
|
|
5250
5288
|
// THE ROW MUST SAY WHICH SERVER IT IS ASKING FOR. It said a bare "server:" — chosen to
|
|
5251
5289
|
// fit the frozen row, which traded away the one thing the label was for. Two questions
|
|
5252
5290
|
// in a row, both reading "server:", and the reader cannot tell which one they are
|
|
5253
5291
|
// answering (reported: "it should say 'Agent + Embedder server' then 'Coding Server',
|
|
5254
5292
|
// it only says 'server:'"). `short` is the role in the fewest words that still name it.
|
|
5255
5293
|
const typed = (await ask(` ${short}: `)).trim();
|
|
5256
|
-
|
|
5294
|
+
// "s" is the advertised skip (#301); the older words still work.
|
|
5295
|
+
if (ui.readStepAnswer(typed).kind === "skip") return null;
|
|
5257
5296
|
// EVERY READING OF A BARE HOSTNAME, in order. Typing "ollama2.gomarsic.cc" means
|
|
5258
5297
|
// https://…:443 behind a proxy, while "192.168.1.40" means http://…:11434 — one guess
|
|
5259
5298
|
// is right for one of them and produces a timeout that looks like a dead server for the
|
|
@@ -5266,15 +5305,33 @@ async function reconfigureOllamaServer() {
|
|
|
5266
5305
|
let n = null;
|
|
5267
5306
|
let models = null;
|
|
5268
5307
|
let lastErr = null;
|
|
5269
|
-
|
|
5270
|
-
|
|
5271
|
-
|
|
5272
|
-
|
|
5273
|
-
|
|
5274
|
-
|
|
5275
|
-
|
|
5276
|
-
|
|
5308
|
+
// #301: a TYPED public address may need a key — asked before listing, so a server that
|
|
5309
|
+
// needs one can list its models. A LAN box is never asked.
|
|
5310
|
+
let key = "";
|
|
5311
|
+
if (ui.shouldAskKey({ typed, host: candidates[0].host })) key = await askKeyFor(candidates[0].host);
|
|
5312
|
+
const tryAll = async () => {
|
|
5313
|
+
for (const cand of candidates) {
|
|
5314
|
+
try {
|
|
5315
|
+
models = await load(cand, key);
|
|
5316
|
+
n = cand;
|
|
5317
|
+
if (candidates.length > 1) console.log(dim(` using ${os.displayUrl(cand.url)}`));
|
|
5318
|
+
return;
|
|
5319
|
+
} catch (err) {
|
|
5320
|
+
lastErr = err;
|
|
5321
|
+
}
|
|
5277
5322
|
}
|
|
5323
|
+
};
|
|
5324
|
+
await tryAll();
|
|
5325
|
+
// Refused for want of credentials: ask now, whatever the address looked like, and list again.
|
|
5326
|
+
if (!n && ui.needsKeyAfterStatus(lastErr?.httpStatus) && !key) {
|
|
5327
|
+
console.log(yellow(` ${candidates[0].host} needs an API key to list its models.`));
|
|
5328
|
+
typedKeys.delete(candidates[0].host);
|
|
5329
|
+
key = await askKeyFor(candidates[0].host);
|
|
5330
|
+
if (key) await tryAll();
|
|
5331
|
+
}
|
|
5332
|
+
if (!n && ui.needsKeyAfterStatus(lastErr?.httpStatus)) {
|
|
5333
|
+
console.log(red(` ${candidates[0].host} refused the request (HTTP ${lastErr.httpStatus})${key ? " — that key was not accepted" : ""}.`));
|
|
5334
|
+
continue;
|
|
5278
5335
|
}
|
|
5279
5336
|
if (!n) {
|
|
5280
5337
|
if (lastErr?.httpStatus === 404) {
|
|
@@ -5301,7 +5358,7 @@ async function reconfigureOllamaServer() {
|
|
|
5301
5358
|
const need = os.checkRequired(models);
|
|
5302
5359
|
if (!need.ok) {
|
|
5303
5360
|
for (const l of os.describeNotReady(n.host, need.missing)) console.log(yellow(` ${l}`));
|
|
5304
|
-
console.log(dim(" (or type
|
|
5361
|
+
console.log(dim(" (or type s to skip — you can set it later with /model)"));
|
|
5305
5362
|
continue;
|
|
5306
5363
|
}
|
|
5307
5364
|
console.log(dim(` ✓ ${n.host} has the ${os.REQUIRED_AGENT_PREFIX} family and the embedder`));
|
|
@@ -5324,10 +5381,10 @@ async function reconfigureOllamaServer() {
|
|
|
5324
5381
|
if (agentOnProvider) {
|
|
5325
5382
|
const host = hostOf(String(settings.agentModelUrl ?? ""));
|
|
5326
5383
|
console.log(dim(` The agent model is on ${host} (OpenAI-compatible) — kept as it is; change it with /agent-model.`));
|
|
5327
|
-
console.log(dim(" Enter keeps the current address
|
|
5384
|
+
console.log(dim(" Enter keeps the current address."));
|
|
5328
5385
|
} else {
|
|
5329
5386
|
console.log(dim(" TWO servers may be used: the agent + embedder on one, the coder on another."));
|
|
5330
|
-
console.log(dim(" Enter keeps the current address
|
|
5387
|
+
console.log(dim(" Enter keeps the current address."));
|
|
5331
5388
|
agent = await askServer("Agent + embedding server", curAgent, {
|
|
5332
5389
|
require: true,
|
|
5333
5390
|
short: "agent + embed server",
|
|
@@ -5472,6 +5529,32 @@ async function reconfigureOllamaServer() {
|
|
|
5472
5529
|
console.log(GUTTER + dim("if this machine's local API is old, restart it with `gu api restart`.\n"));
|
|
5473
5530
|
return;
|
|
5474
5531
|
}
|
|
5532
|
+
// #301: THE KEYS TYPED FOR THE SERVERS, bound to their rows now that the rows exist (the API
|
|
5533
|
+
// refuses a key for a row it does not have). Before the cloud check below, so a key typed for
|
|
5534
|
+
// the Ollama cloud is not asked for a second time. Reported by host, never by value.
|
|
5535
|
+
{
|
|
5536
|
+
const used = [agent?.normalized?.url, coder?.normalized?.url].filter(Boolean);
|
|
5537
|
+
// By HOSTNAME: the key was asked for urlCandidates' host, which carries no port, while a URL's
|
|
5538
|
+
// .host does — box.example.com:8443 would otherwise never match its own key.
|
|
5539
|
+
const hostnameOf = (u) => { try { return new URL(u).hostname; } catch { return ""; } };
|
|
5540
|
+
const pending = [...typedKeys].filter(([host, k]) => k && used.some((u) => hostnameOf(u) === host));
|
|
5541
|
+
if (pending.length) {
|
|
5542
|
+
const fresh = await withPendingStatus("storing the key", fetchWorkerSettings);
|
|
5543
|
+
const ps = await import("./coding-provider-setup.mjs");
|
|
5544
|
+
for (const [host, k] of pending) {
|
|
5545
|
+
const url = used.find((u) => hostnameOf(u) === host);
|
|
5546
|
+
const found = ps.providerRowForUrl(ps.existingBackendRows(fresh ?? {}), url);
|
|
5547
|
+
if (!found) {
|
|
5548
|
+
console.log(yellow(` no server row holds ${host} yet — add its key with ctrl+k in /model.`));
|
|
5549
|
+
continue;
|
|
5550
|
+
}
|
|
5551
|
+
const ok = await withPendingStatus("saving the key", () => addCodingKey(host, k, found.id));
|
|
5552
|
+
console.log(ok
|
|
5553
|
+
? GUTTER + dim(`✓ key stored for ${host} — the server's default key`)
|
|
5554
|
+
: red(` couldn't store the key for ${host} — add it with ctrl+k.`));
|
|
5555
|
+
}
|
|
5556
|
+
}
|
|
5557
|
+
}
|
|
5475
5558
|
// THE OLLAMA CLOUD NEEDS ITS OWN KEY. Reported: choosing https://ollama.com here listed its 20 models
|
|
5476
5559
|
// (that list is public) and saved, without a key — and the next turn failed "REJECTED the coding API
|
|
5477
5560
|
// key", having borrowed an unrelated sk-… key. Asked for now, bound to the row just saved.
|
|
@@ -9634,6 +9717,9 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
|
|
|
9634
9717
|
// Task #104: this turn's OUTPUT-token total, read from the job doc each poll. Folded into
|
|
9635
9718
|
// the lifetime per-workspace + global counters once the turn completes.
|
|
9636
9719
|
let latestOutputTokens = 0;
|
|
9720
|
+
// Task #298: the current model call's own ↑/↓, read from the polled breakdown. null until
|
|
9721
|
+
// this turn reports a step — never the previous turn's, which the kept breakdown still holds.
|
|
9722
|
+
let liveStepTokens = null;
|
|
9637
9723
|
let outputTokensPosted = false; // guard: increment the lifetime counters once per turn
|
|
9638
9724
|
// Task #142: when this turn was submitted, and when its first full prompt reached the coding
|
|
9639
9725
|
// model. The gap between them is prompt assembly + RAG retrieval — the thing the user asked
|
|
@@ -9875,10 +9961,18 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
|
|
|
9875
9961
|
// Advanced once per tick — this branch runs ~8x/second while the agent is thinking, which is
|
|
9876
9962
|
// what makes the climb visible at all. Parked (streaming) the block is not rebuilt, so the
|
|
9877
9963
|
// figure holds rather than jittering against flowing output.
|
|
9878
|
-
|
|
9879
|
-
|
|
9880
|
-
//
|
|
9881
|
-
|
|
9964
|
+
// THIS STEP, NOT THE TURN (task #298). The turn totals were exactly the bar's "sent" and
|
|
9965
|
+
// "↓ out" one row below — the same two numbers twice. ↑ is the current model call's prompt,
|
|
9966
|
+
// ↓ its output (known when the call returns); both start from zero at each new step.
|
|
9967
|
+
const st = liveStepTokens;
|
|
9968
|
+
if (!st || st.step !== shownStepNo) {
|
|
9969
|
+
shownStepNo = st ? st.step : 0;
|
|
9970
|
+
shownInputTokens = 0;
|
|
9971
|
+
shownOutputTokens = 0;
|
|
9972
|
+
}
|
|
9973
|
+
shownOutputTokens = easeCount(shownOutputTokens, st ? st.stepOut : 0);
|
|
9974
|
+
shownInputTokens = easeCount(shownInputTokens, st ? st.stepIn : 0);
|
|
9975
|
+
// ↑ input then ↓ output (#53). Each absent until known, never a "0" that claims a measurement.
|
|
9882
9976
|
const outLabel =
|
|
9883
9977
|
(shownInputTokens > 0 ? dim(" · ↑ ") + accent(fmtTokens(shownInputTokens)) : "") +
|
|
9884
9978
|
(shownOutputTokens > 0 ? dim(" · ↓ ") + accent(fmtTokens(shownOutputTokens)) : "");
|
|
@@ -10493,6 +10587,7 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
|
|
|
10493
10587
|
// what is in the context right now, so it must be allowed to shrink when a trim lands.
|
|
10494
10588
|
if (job.agentPromptBreakdown && typeof job.agentPromptBreakdown === "object") {
|
|
10495
10589
|
lastPromptBreakdown = job.agentPromptBreakdown;
|
|
10590
|
+
liveStepTokens = readStepTokens(job.agentPromptBreakdown);
|
|
10496
10591
|
// Persisted per folder so the next `gu` here starts from the real picture instead
|
|
10497
10592
|
// of a zero that looks like a failure. Fire-and-forget: a bar value is never worth
|
|
10498
10593
|
// delaying a turn for, and losing one is harmless. Shown while attached (it is what
|
|
@@ -10853,8 +10948,18 @@ async function main() {
|
|
|
10853
10948
|
// recorded, so once the choice was saved this block was skipped and the plan sat on disk
|
|
10854
10949
|
// untouched. Picking it up here is what makes the sentence true.
|
|
10855
10950
|
const { runClientExpertSetup } = await import("./client-expert-run.mjs");
|
|
10856
|
-
const
|
|
10951
|
+
const { SKIP_ALL } = await import("./model-setup-ui.mjs");
|
|
10952
|
+
// #301: keys (and the MongoDB password) through the REPL's own masked prompt; a cancelled
|
|
10953
|
+
// one means "skip the rest", not a crash of the setup.
|
|
10954
|
+
const toSkipAll = (e) => (e instanceof PromptCancelled ? SKIP_ALL : Promise.reject(e));
|
|
10955
|
+
const setup = await runClientExpertSetup({
|
|
10956
|
+
ask,
|
|
10957
|
+
// Esc at a model question throws PromptCancelled here; in setup it means "skip all".
|
|
10958
|
+
askStep: (q) => ask(q).catch(toSkipAll),
|
|
10959
|
+
askSecret: (q) => askSecret(q).catch(toSkipAll),
|
|
10960
|
+
});
|
|
10857
10961
|
if (setup?.complete) reloadWorkerEnv();
|
|
10962
|
+
if (setup?.loadPreset) openPresetsAtStart = true;
|
|
10858
10963
|
}
|
|
10859
10964
|
} catch {
|
|
10860
10965
|
/* never block the REPL on the mode question — the banner still shows what is configured */
|
|
@@ -11605,6 +11710,17 @@ async function main() {
|
|
|
11605
11710
|
// rows it pushes up are real output rather than half-drawn setup.
|
|
11606
11711
|
syncBarPinning();
|
|
11607
11712
|
|
|
11713
|
+
// #301: setup's "Load a saved preset" — the picker opens now that the terminal exists. The whole
|
|
11714
|
+
// /load-presets flow (the list, the plan, the confirmation, the key) is that command's own.
|
|
11715
|
+
if (openPresetsAtStart) {
|
|
11716
|
+
openPresetsAtStart = false;
|
|
11717
|
+
try {
|
|
11718
|
+
await runLoadPresetsCommand("/load-presets");
|
|
11719
|
+
} catch (e) {
|
|
11720
|
+
console.log(GUTTER + dim(`(could not open the presets: ${e?.message ?? e} — run /load-presets)`));
|
|
11721
|
+
}
|
|
11722
|
+
}
|
|
11723
|
+
|
|
11608
11724
|
for (;;) {
|
|
11609
11725
|
stopPendingStatus(); // nothing from a previous turn may still be animating (task #140)
|
|
11610
11726
|
// The bar and the input line live in the frozen rows at the bottom of the window (task
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE WAY OUT OF THE MODEL QUESTIONS, and the key question that follows a typed server (#301).
|
|
3
|
+
*
|
|
4
|
+
* Reported: at "Agent + embedding server [https://ollama1.gomarsic.cc]:" there was no visible way
|
|
5
|
+
* to NOT choose. Enter takes the default server; a decline existed ("n", "no", "skip", "-") but no
|
|
6
|
+
* prompt said so, and one hint even claimed "press Enter with no address to skip", which was the
|
|
7
|
+
* opposite of what Enter does. Some people want no model setup at all and to load a saved preset.
|
|
8
|
+
*
|
|
9
|
+
* The decisions, agreed with the user (2026-09-29):
|
|
10
|
+
* · ASK ONCE, UP FRONT — guided / load a preset / skip. Enter = guided, so today's flow is one
|
|
11
|
+
* keypress away and unchanged.
|
|
12
|
+
* · TWO WAYS OUT, NOT FIVE — "s" skips a step, Esc skips all model setup. The old words still
|
|
13
|
+
* work, silently.
|
|
14
|
+
* · FULL HINT ONCE, THEN SHORT — a hint under every prompt is noise by the fourth.
|
|
15
|
+
* · A KEY ONLY WHEN ONE MAY BE NEEDED — a LAN Ollama box never needs one, a public https
|
|
16
|
+
* address may, and a server that answers 401/403 certainly does.
|
|
17
|
+
* · CONFIRM THE KEY WITHOUT SHOWING IT — a masked prompt gives no sign it took anything.
|
|
18
|
+
*
|
|
19
|
+
* PURE. Everything here is strings in, strings out, so the whole table is a test.
|
|
20
|
+
*/
|
|
21
|
+
import { hostLocality } from "./coding-provider-setup.mjs";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* What an Esc-aware prompt resolves to when Esc is pressed. A string no one can type (it starts
|
|
25
|
+
* with NUL), so it can travel through every `ask` that returns strings without a new type.
|
|
26
|
+
*/
|
|
27
|
+
export const SKIP_ALL = "\u0000skip-all";
|
|
28
|
+
|
|
29
|
+
/** Esc, as readline reports it: name "escape" (meta:true on most terminals — accept either). */
|
|
30
|
+
export function isEscapeKey(key) {
|
|
31
|
+
return Boolean(key) && (key.name === "escape" || (key.meta && key.sequence === "\x1b"));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* One answer at a model prompt → what it means.
|
|
36
|
+
* { kind: "all" } Esc: skip all model setup
|
|
37
|
+
* { kind: "skip" } "s" (or the older n / no / skip / -): skip this step
|
|
38
|
+
* { kind: "answer", text } anything else, including "" (= the default)
|
|
39
|
+
*/
|
|
40
|
+
export function readStepAnswer(raw) {
|
|
41
|
+
if (raw === SKIP_ALL) return { kind: "all" };
|
|
42
|
+
const text = String(raw ?? "").trim();
|
|
43
|
+
if (/^(s|n|no|skip|-)$/i.test(text)) return { kind: "skip" };
|
|
44
|
+
return { kind: "answer", text };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const FULL_HINT = "Enter = use this · s = skip this step · Esc = skip all model setup";
|
|
48
|
+
export const SHORT_HINT = "s skip · esc skip all";
|
|
49
|
+
|
|
50
|
+
/** A hinter: the full hint the first time it is asked for, the short one after. */
|
|
51
|
+
export function createHinter() {
|
|
52
|
+
let shown = false;
|
|
53
|
+
return () => {
|
|
54
|
+
if (shown) return SHORT_HINT;
|
|
55
|
+
shown = true;
|
|
56
|
+
return FULL_HINT;
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Should typing THIS address be followed by "API key for <host>"?
|
|
62
|
+
*
|
|
63
|
+
* Only for an address that was TYPED (Enter on the default is today's path and stays one key),
|
|
64
|
+
* and only when the host is public: a LAN box (192.168.x, 10.x, *.local, a bare hostname,
|
|
65
|
+
* localhost) is an Ollama that takes no key, and asking there is a question with one answer.
|
|
66
|
+
* A server that answers 401/403 is asked regardless — see needsKeyAfterStatus.
|
|
67
|
+
*/
|
|
68
|
+
export function shouldAskKey({ typed, host }) {
|
|
69
|
+
if (!String(typed ?? "").trim()) return false;
|
|
70
|
+
return hostLocality(host) === "public";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The server refused to list its models without credentials. */
|
|
74
|
+
export function needsKeyAfterStatus(status) {
|
|
75
|
+
return status === 401 || status === 403;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The last 4 characters, in the bar's own style — enough to tell keys apart, not to use one. */
|
|
79
|
+
export function keyTail(key) {
|
|
80
|
+
const k = String(key ?? "").trim();
|
|
81
|
+
return k.length > 4 ? `····${k.slice(-4)}` : "····";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The line printed after the masked prompt, so the person knows whether it took anything. */
|
|
85
|
+
export function keyConfirmation(host, key) {
|
|
86
|
+
return String(key ?? "").trim()
|
|
87
|
+
? `✓ key saved for ${host} (${keyTail(key)})`
|
|
88
|
+
: `no key for ${host}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The up-front question. `presets` = how many saved presets there are: with none, option 2 still
|
|
93
|
+
* shows (so the feature is discoverable) but says how to make one instead of offering nothing.
|
|
94
|
+
*/
|
|
95
|
+
export function modelSetupMenu({ presets = 0 } = {}) {
|
|
96
|
+
return [
|
|
97
|
+
"How do you want to set up the models?",
|
|
98
|
+
" 1 Guided — pick the servers and models (Enter)",
|
|
99
|
+
presets > 0
|
|
100
|
+
? ` 2 Load a saved preset (${presets} saved)`
|
|
101
|
+
: " 2 Load a saved preset (none saved yet — /save-preset makes one)",
|
|
102
|
+
" 3 Skip for now — set them later with /model",
|
|
103
|
+
];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** An answer to the up-front question → "guided" | "preset" | "skip", or null to ask again. */
|
|
107
|
+
export function parseModelSetupChoice(raw) {
|
|
108
|
+
if (raw === SKIP_ALL) return "skip";
|
|
109
|
+
const t = String(raw ?? "").trim().toLowerCase();
|
|
110
|
+
if (t === "" || t === "1" || t === "guided" || t === "g") return "guided";
|
|
111
|
+
if (t === "2" || t === "preset" || t === "p") return "preset";
|
|
112
|
+
if (t === "3" || t === "s" || t === "skip" || t === "n" || t === "no" || t === "-") return "skip";
|
|
113
|
+
return null;
|
|
114
|
+
}
|