@tiens.nguyen/gu-cli 1.0.768 → 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/gu-repl.mjs +116 -16
- package/model-setup-ui.mjs +114 -0
- package/ollama-setup.mjs +128 -23
- package/package.json +3 -1
- package/setup-prompts.mjs +82 -0
- package/terminal-layout.mjs +31 -1
package/client-expert-api.mjs
CHANGED
|
@@ -516,6 +516,23 @@ export const CLIENT_EXPERT_MODELS = {
|
|
|
516
516
|
* is written — while Kimi is registered as "openai" so `/model` lists it and asks for the key
|
|
517
517
|
* only if the user actually switches. One slot per kind, so these cannot collide.
|
|
518
518
|
*/
|
|
519
|
+
/** The row setup writes for an agent server that differs from the coder's and has a key (#301). */
|
|
520
|
+
export const AGENT_ROW_ID = "ollama-agent";
|
|
521
|
+
/** The implicit id of the coder's row — setup writes it without one; the API names it by kind. */
|
|
522
|
+
export const CODER_ROW_ID = "ollama";
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Which row a key typed at setup binds to (#301): the coder's row for the coder's server (or for
|
|
526
|
+
* the agent's when they share one), the agent row otherwise. "" when neither server was used.
|
|
527
|
+
*/
|
|
528
|
+
export function rowIdForKey(models, url) {
|
|
529
|
+
const M = models ?? {};
|
|
530
|
+
const codeUrl = M.codeUrl || M.url;
|
|
531
|
+
if (url && url === codeUrl) return CODER_ROW_ID;
|
|
532
|
+
if (url && url === M.url) return AGENT_ROW_ID;
|
|
533
|
+
return "";
|
|
534
|
+
}
|
|
535
|
+
|
|
519
536
|
export function codingBackendsForClientExpert(models = CLIENT_EXPERT_MODELS) {
|
|
520
537
|
const M = models ?? CLIENT_EXPERT_MODELS;
|
|
521
538
|
// The chat model is offered too, so `/model` can drop to the fast one for simple work, plus
|
|
@@ -525,17 +542,33 @@ export function codingBackendsForClientExpert(models = CLIENT_EXPERT_MODELS) {
|
|
|
525
542
|
// SHARED_ALT_CODE_MODEL is the seeded default for the shared box. Deduped and stripped of the
|
|
526
543
|
// primary coder, because a `/model` list that offers the model you are already on — twice —
|
|
527
544
|
// is a list nobody trusts.
|
|
528
|
-
|
|
545
|
+
//
|
|
546
|
+
// THE CODER'S OWN SERVER (#301). Setup has asked "Coding-model server" separately since #220 and
|
|
547
|
+
// returned it as codeUrl, but this row was always built on M.url — the AGENT's server — so a
|
|
548
|
+
// coder chosen on a second box was silently configured on the first. The agent model is offered
|
|
549
|
+
// on the coder's row only when it lives on that same server; elsewhere it is not there to offer.
|
|
550
|
+
const codeUrl = M.codeUrl || M.url;
|
|
551
|
+
const sameServer = codeUrl === M.url;
|
|
552
|
+
const offered = [...(sameServer ? [M.chat] : []), ...(M.altCoders ?? [SHARED_ALT_CODE_MODEL])].filter(
|
|
529
553
|
(name, i, all) => name && name !== M.code && all.indexOf(name) === i,
|
|
530
554
|
);
|
|
555
|
+
// AN AGENT SERVER WITH A KEY NEEDS A ROW OF ITS OWN (#301). The API finds the agent's (and the
|
|
556
|
+
// embedder's) key by matching agentModelUrl to a row's URL — no row, no key. Only when the key
|
|
557
|
+
// exists: a keyless agent box needs no row, and one would add a server to /model for nothing.
|
|
558
|
+
// Explicit id, because two id-less ollama rows would both claim the implicit id "ollama".
|
|
559
|
+
const agentKeyed =
|
|
560
|
+
!sameServer && Array.isArray(M.keys) && M.keys.some((k) => k?.url === M.url && k?.key);
|
|
531
561
|
return [
|
|
532
562
|
{
|
|
533
563
|
kind: "ollama",
|
|
534
564
|
enabled: true,
|
|
535
|
-
url:
|
|
565
|
+
url: codeUrl,
|
|
536
566
|
defaultModel: M.code,
|
|
537
567
|
models: offered,
|
|
538
568
|
},
|
|
569
|
+
...(agentKeyed
|
|
570
|
+
? [{ id: AGENT_ROW_ID, kind: "ollama", enabled: true, url: M.url, defaultModel: M.chat, models: [] }]
|
|
571
|
+
: []),
|
|
539
572
|
{
|
|
540
573
|
kind: "openai",
|
|
541
574
|
enabled: true,
|
|
@@ -627,8 +660,14 @@ export function backfillCodingModels(existing = {}) {
|
|
|
627
660
|
* model names to a list that already exists; see its docstring for why that is not "overwriting
|
|
628
661
|
* a model the user has since chosen".
|
|
629
662
|
*/
|
|
630
|
-
export function settingsPatch({ existing = {}, models = CLIENT_EXPERT_MODELS } = {}) {
|
|
663
|
+
export function settingsPatch({ existing = {}, models = CLIENT_EXPERT_MODELS, skipModels = false } = {}) {
|
|
631
664
|
const patch = {};
|
|
665
|
+
// #301: "Skip for now" (or Esc) at setup — NO model field is written: not the shared box, not a
|
|
666
|
+
// half. The bar then says what is unset and /model sets it. Only the non-model defaults below.
|
|
667
|
+
if (skipModels) {
|
|
668
|
+
if (typeof existing.agentSaveFullResponse !== "boolean") patch.agentSaveFullResponse = true;
|
|
669
|
+
return patch;
|
|
670
|
+
}
|
|
632
671
|
// `models` is either the seeded shared-Ollama defaults (nobody was asked) or the box the user
|
|
633
672
|
// chose at setup (task #220). Everything below is written in terms of M, so the two paths
|
|
634
673
|
// cannot drift into configuring different fields.
|
|
@@ -638,7 +677,10 @@ export function settingsPatch({ existing = {}, models = CLIENT_EXPERT_MODELS } =
|
|
|
638
677
|
Boolean(existing.agentModelUrl?.trim()) ||
|
|
639
678
|
Boolean(existing.agentModel?.trim()) ||
|
|
640
679
|
Boolean(existing.agentModelName?.trim());
|
|
641
|
-
|
|
680
|
+
// A ROLE SKIPPED AT SETUP IS LEFT UNSET, not half-written (#301): an agent URL with no model
|
|
681
|
+
// name is the "default_model" sentinel on the wire, and a coder row with an empty model is a coder
|
|
682
|
+
// that answers nothing. Only the roles that were actually chosen are written.
|
|
683
|
+
if (!hasAgent && M.chat) {
|
|
642
684
|
patch.agentModelUrl = M.url;
|
|
643
685
|
const name = M.chat;
|
|
644
686
|
// agentModelNAME is the field that matters. With a direct agent URL the API reads
|
|
@@ -664,17 +706,17 @@ export function settingsPatch({ existing = {}, models = CLIENT_EXPERT_MODELS } =
|
|
|
664
706
|
(Array.isArray(existing.agentCodingBackends) && existing.agentCodingBackends.length > 0) ||
|
|
665
707
|
Boolean(existing.agentCodingModelUrl?.trim()) ||
|
|
666
708
|
Boolean(existing.agentCodingModelName?.trim());
|
|
667
|
-
if (!hasCoder) {
|
|
709
|
+
if (!hasCoder && M.code) {
|
|
668
710
|
patch.agentCodingBackends = codingBackendsForClientExpert(M);
|
|
669
711
|
patch.agentCodingDefaultKind = "ollama";
|
|
670
712
|
// Legacy mirror, so an older worker resolves the same coder.
|
|
671
713
|
patch.agentCodingModelKind = "ollama";
|
|
672
|
-
patch.agentCodingModelUrl = M.url;
|
|
714
|
+
patch.agentCodingModelUrl = M.codeUrl || M.url;
|
|
673
715
|
patch.agentCodingModelName = M.code;
|
|
674
716
|
// NO api key is stored. The default coder is kind "ollama", which the API does not require
|
|
675
717
|
// one for — and a placeholder here would be SENT to Kimi the moment someone switched with
|
|
676
718
|
// `/model`, failing at the provider with an auth error nobody expected.
|
|
677
|
-
} else {
|
|
719
|
+
} else if (hasCoder) {
|
|
678
720
|
// Already configured — so the seed above will never run again on this machine. Add any
|
|
679
721
|
// newly-offered shared-Ollama models to the list it was seeded with, without changing which
|
|
680
722
|
// coder it uses. See backfillCodingModels.
|
|
@@ -752,6 +794,8 @@ export async function seedSettings({
|
|
|
752
794
|
// question was skipped or declined. Passed straight to settingsPatch so there is exactly one
|
|
753
795
|
// place that decides what a Client Expert machine is configured with.
|
|
754
796
|
models = CLIENT_EXPERT_MODELS,
|
|
797
|
+
// #301: the model questions were skipped — write no model field at all.
|
|
798
|
+
skipModels = false,
|
|
755
799
|
} = {}) {
|
|
756
800
|
const { MongoClient } = loadMongoDriver(apiDir);
|
|
757
801
|
const client = new MongoClient(mongoUri, { serverSelectionTimeoutMS: 8000 });
|
|
@@ -759,7 +803,7 @@ export async function seedSettings({
|
|
|
759
803
|
try {
|
|
760
804
|
const col = client.db(dbName).collection("settings");
|
|
761
805
|
const existing = (await col.findOne({ userId })) ?? {};
|
|
762
|
-
const patch = settingsPatch({ existing, models });
|
|
806
|
+
const patch = settingsPatch({ existing, models, skipModels });
|
|
763
807
|
if (Object.keys(patch).length > 0) {
|
|
764
808
|
await col.updateOne(
|
|
765
809
|
{ userId },
|
|
@@ -773,6 +817,43 @@ export async function seedSettings({
|
|
|
773
817
|
}
|
|
774
818
|
}
|
|
775
819
|
|
|
820
|
+
/**
|
|
821
|
+
* Save the API keys typed at setup (#301), each bound to its server's row, through the local API
|
|
822
|
+
* — the same POST /coding-keys the terminal uses, so the first key for a server becomes its
|
|
823
|
+
* default by the API's own rule (#245) rather than by anything written here. Runs AFTER
|
|
824
|
+
* seedSettings: the API refuses a key for a row that does not exist yet.
|
|
825
|
+
*
|
|
826
|
+
* Returns [{ host, ok }]. Never throws and never prints the key: a key that failed to save is
|
|
827
|
+
* reported by host, and can be added again with ctrl+k in /model.
|
|
828
|
+
*/
|
|
829
|
+
export async function saveSetupKeys({ apiBase = LOCAL_API_BASE, workerKey, models, fetchImpl = fetch } = {}) {
|
|
830
|
+
const out = [];
|
|
831
|
+
for (const { url, key } of Array.isArray(models?.keys) ? models.keys : []) {
|
|
832
|
+
let host = url;
|
|
833
|
+
try {
|
|
834
|
+
host = new URL(url).host;
|
|
835
|
+
} catch {
|
|
836
|
+
/* keep the url */
|
|
837
|
+
}
|
|
838
|
+
const backendId = rowIdForKey(models, url);
|
|
839
|
+
if (!key || !backendId) continue;
|
|
840
|
+
let ok = false;
|
|
841
|
+
try {
|
|
842
|
+
const r = await fetchImpl(`${apiBase}/api/worker/coding-keys`, {
|
|
843
|
+
method: "POST",
|
|
844
|
+
headers: { "Content-Type": "application/json", "X-Worker-Key": workerKey },
|
|
845
|
+
body: JSON.stringify({ name: host, apiKey: key, backendId }),
|
|
846
|
+
signal: AbortSignal.timeout(10000),
|
|
847
|
+
});
|
|
848
|
+
ok = r.ok;
|
|
849
|
+
} catch {
|
|
850
|
+
ok = false;
|
|
851
|
+
}
|
|
852
|
+
out.push({ host, ok });
|
|
853
|
+
}
|
|
854
|
+
return out;
|
|
855
|
+
}
|
|
856
|
+
|
|
776
857
|
/**
|
|
777
858
|
* THE NEW OLLAMA SERVER'S ROWS, MERGED INTO WHAT IS ALREADY THERE — never replacing it (#43).
|
|
778
859
|
*
|
package/client-expert-run.mjs
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
probeLocalApi,
|
|
30
30
|
writeWorkerEnv,
|
|
31
31
|
seedSettings,
|
|
32
|
+
saveSetupKeys,
|
|
32
33
|
modelsAlreadyConfigured,
|
|
33
34
|
CLIENT_EXPERT_MODELS,
|
|
34
35
|
LOCAL_API_PORT,
|
|
@@ -37,6 +38,9 @@ import {
|
|
|
37
38
|
LOCAL_MONGO_URI,
|
|
38
39
|
} from "./client-expert-api.mjs";
|
|
39
40
|
import { askOllamaSetup, localOllamaDefault } from "./ollama-setup.mjs";
|
|
41
|
+
import { SKIP_ALL, createHinter, modelSetupMenu, parseModelSetupChoice } from "./model-setup-ui.mjs";
|
|
42
|
+
import { presetFilesIn } from "./presets-transfer.mjs";
|
|
43
|
+
import { readdirSync, statSync, readFileSync } from "node:fs";
|
|
40
44
|
import { parseOllamaTags, formatSize } from "./agent-model-command.mjs";
|
|
41
45
|
import {
|
|
42
46
|
planSetup,
|
|
@@ -66,15 +70,46 @@ class SkipModelQuestion extends Error {}
|
|
|
66
70
|
* (this returns []), and those two get different messages because they need different fixes.
|
|
67
71
|
* A short timeout, because a box that has not answered in eight seconds is not going to.
|
|
68
72
|
*/
|
|
69
|
-
async function fetchOllamaTags(url) {
|
|
70
|
-
const res = await fetch(url, {
|
|
71
|
-
|
|
73
|
+
async function fetchOllamaTags(url, { apiKey = "" } = {}) {
|
|
74
|
+
const res = await fetch(url, {
|
|
75
|
+
signal: AbortSignal.timeout(8000),
|
|
76
|
+
// #301: a key typed for this server. Absent → the request is exactly what it always was.
|
|
77
|
+
...(apiKey ? { headers: { Authorization: `Bearer ${apiKey}` } } : {}),
|
|
78
|
+
});
|
|
79
|
+
if (!res.ok) {
|
|
80
|
+
// The status travels with the error: 401/403 is "needs a key", which the caller asks for.
|
|
81
|
+
const err = new Error(`HTTP ${res.status}`);
|
|
82
|
+
err.status = res.status;
|
|
83
|
+
throw err;
|
|
84
|
+
}
|
|
72
85
|
// parseOllamaTags is tolerant by design — HTML from a proxy, an empty body and a shape from a
|
|
73
86
|
// future Ollama all become []. That is "reachable but not usable", which the caller reports as
|
|
74
87
|
// such rather than as a network failure.
|
|
75
88
|
return parseOllamaTags(await res.json().catch(() => null));
|
|
76
89
|
}
|
|
77
90
|
|
|
91
|
+
/** How many /save-preset files are in the home folder — the up-front question names the count. */
|
|
92
|
+
function countSavedPresets() {
|
|
93
|
+
try {
|
|
94
|
+
const home = homedir();
|
|
95
|
+
const entries = [];
|
|
96
|
+
for (const name of readdirSync(home)) {
|
|
97
|
+
if (!name.toLowerCase().endsWith(".json")) continue;
|
|
98
|
+
const file = join(home, name);
|
|
99
|
+
try {
|
|
100
|
+
const st = statSync(file);
|
|
101
|
+
if (!st.isFile() || st.size > 64 * 1024) continue; // a preset is tiny; never slurp a big file
|
|
102
|
+
entries.push({ name, text: readFileSync(file, "utf8"), mtimeMs: st.mtimeMs });
|
|
103
|
+
} catch {
|
|
104
|
+
/* unreadable — not a preset we can offer */
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return presetFilesIn(entries).length;
|
|
108
|
+
} catch {
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
78
113
|
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
79
114
|
const bold = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
80
115
|
// The brand accent, shared with the REPL — see accent.mjs.
|
|
@@ -339,7 +374,7 @@ export async function checkMongoConnection(uri) {
|
|
|
339
374
|
* Returns { uri, custom } to continue with, or { exit: true } when the user declines — the
|
|
340
375
|
* caller ends setup with the `gu clear` → `gu` hint.
|
|
341
376
|
*/
|
|
342
|
-
export async function askMongoCredentials(ask) {
|
|
377
|
+
export async function askMongoCredentials(ask, askSecret = null) {
|
|
343
378
|
const { host, port } = mongoHostPort(LOCAL_MONGO_URI);
|
|
344
379
|
console.log(dim(` MongoDB is answering at ${host}:${port}.`));
|
|
345
380
|
console.log(dim(" If that is a MongoDB this machine already had, it may have its OWN"));
|
|
@@ -352,7 +387,12 @@ export async function askMongoCredentials(ask) {
|
|
|
352
387
|
// continues: a typo found here costs ten seconds; found at the seed step it costs the run.
|
|
353
388
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
354
389
|
const username = String(await ask(" MongoDB username: ") ?? "").trim();
|
|
355
|
-
|
|
390
|
+
// MASKED when a masked reader is available (#300): a password typed in the clear stays in the
|
|
391
|
+
// scrollback, and in anything copied from it.
|
|
392
|
+
const rawPw = typeof askSecret === "function"
|
|
393
|
+
? await askSecret(" MongoDB password: ")
|
|
394
|
+
: await ask(" MongoDB password: ");
|
|
395
|
+
const password = rawPw === SKIP_ALL ? "" : String(rawPw ?? "");
|
|
356
396
|
const uri = buildMongoUri({ username, password, host, port });
|
|
357
397
|
process.stdout.write(dim(" checking the connection…"));
|
|
358
398
|
const verdict = await checkMongoConnection(uri);
|
|
@@ -589,7 +629,14 @@ export function markInterruptedOnExit(getState, stepId) {
|
|
|
589
629
|
};
|
|
590
630
|
}
|
|
591
631
|
|
|
592
|
-
export async function runClientExpertSetup({
|
|
632
|
+
export async function runClientExpertSetup({
|
|
633
|
+
ask,
|
|
634
|
+
// #301/#300: a MASKED line reader (API keys, the MongoDB password). Absent → no key is asked.
|
|
635
|
+
askSecret = null,
|
|
636
|
+
// #301: an Esc-aware reader for the model questions — Esc resolves it with SKIP_ALL. Only the
|
|
637
|
+
// model questions use it: Esc elsewhere (the MongoDB questions) must not mean "skip models".
|
|
638
|
+
askStep = ask,
|
|
639
|
+
}) {
|
|
593
640
|
const machine = await probeMachine();
|
|
594
641
|
const gate = clientExpertAvailable({ workerEnvExists: false, ...machine });
|
|
595
642
|
if (!gate.available) {
|
|
@@ -707,7 +754,7 @@ export async function runClientExpertSetup({ ask }) {
|
|
|
707
754
|
console.log(dim(" using the MongoDB credentials from the previous run"));
|
|
708
755
|
}
|
|
709
756
|
} else if (!process.env.GONEXT_LOCAL_MONGO_URI) {
|
|
710
|
-
const credsChoice = await askMongoCredentials(ask);
|
|
757
|
+
const credsChoice = await askMongoCredentials(ask, askSecret);
|
|
711
758
|
if (credsChoice.exit) {
|
|
712
759
|
console.log("\n " + dim("No working MongoDB credentials — setup stops here."));
|
|
713
760
|
console.log(dim(" To start over: `gu clear`, then `gu` again.\n"));
|
|
@@ -902,6 +949,27 @@ export async function runClientExpertSetup({ ask }) {
|
|
|
902
949
|
console.log(dim(" server this machine uses."));
|
|
903
950
|
throw new SkipModelQuestion();
|
|
904
951
|
}
|
|
952
|
+
// ASK ONCE, UP FRONT (#301): guided / load a preset / skip. Enter = guided, so today's flow is
|
|
953
|
+
// unchanged for anyone who just presses Enter; the other two are one digit away instead of an
|
|
954
|
+
// escape hunt through four questions.
|
|
955
|
+
const presets = countSavedPresets();
|
|
956
|
+
console.log("");
|
|
957
|
+
const [title, ...rows] = modelSetupMenu({ presets });
|
|
958
|
+
console.log(" " + bold(title));
|
|
959
|
+
for (const r of rows) console.log(dim(" " + r));
|
|
960
|
+
let path = null;
|
|
961
|
+
for (let t = 0; t < 3 && !path; t++) {
|
|
962
|
+
path = parseModelSetupChoice(await askStep(" choose [1]: "));
|
|
963
|
+
if (path === "preset" && presets === 0) {
|
|
964
|
+
console.log(dim(" No presets saved yet. Save one later with /save-preset — pick 1 or 3 for now."));
|
|
965
|
+
path = null;
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
path = path ?? "guided";
|
|
969
|
+
if (path !== "guided") {
|
|
970
|
+
chosenModels = { skipped: true, keys: [], loadPreset: path === "preset" };
|
|
971
|
+
throw new SkipModelQuestion();
|
|
972
|
+
}
|
|
905
973
|
console.log("");
|
|
906
974
|
console.log(" " + bold("Which Ollama server should this machine use?"));
|
|
907
975
|
// THIS MACHINE FIRST, when it has one. Client Expert is the mode whose whole promise is
|
|
@@ -923,10 +991,12 @@ export async function runClientExpertSetup({ ask }) {
|
|
|
923
991
|
),
|
|
924
992
|
);
|
|
925
993
|
console.log(dim(" ollama.local, http://box:11434 — and pick from what it actually has."));
|
|
926
|
-
chosenModels = await askOllamaSetup(
|
|
994
|
+
chosenModels = await askOllamaSetup(askStep, {
|
|
927
995
|
defaultUrl,
|
|
928
996
|
fetchTags: fetchOllamaTags,
|
|
929
997
|
formatSize,
|
|
998
|
+
askSecret,
|
|
999
|
+
hint: createHinter(),
|
|
930
1000
|
// THE SHIPPED DEFAULT WINS WHEN THE BOX HAS IT. Without this a fresh machine falls back to
|
|
931
1001
|
// a size heuristic, and on a box with several similar builds that picks by size alone —
|
|
932
1002
|
// observed on ollama2, where gemma4-quadro:latest and gemma4-quadro-49k:latest are
|
|
@@ -939,18 +1009,38 @@ export async function runClientExpertSetup({ ask }) {
|
|
|
939
1009
|
// The skip is not a failure and says nothing extra; it has already printed its own line.
|
|
940
1010
|
if (!(e instanceof SkipModelQuestion)) {
|
|
941
1011
|
console.log(dim(` (could not ask: ${e.message} — using the shared Ollama)`));
|
|
1012
|
+
chosenModels = null;
|
|
942
1013
|
}
|
|
943
|
-
|
|
1014
|
+
// A SkipModelQuestion keeps whatever it set: undefined (already configured) or the skip.
|
|
1015
|
+
chosenModels = chosenModels ?? null;
|
|
944
1016
|
}
|
|
1017
|
+
// #301: skipped on purpose (up front, "s" at both servers, or Esc) → no model field is written.
|
|
1018
|
+
const skipModels = Boolean(chosenModels?.skipped);
|
|
1019
|
+
const loadPreset = Boolean(chosenModels?.loadPreset);
|
|
945
1020
|
|
|
946
1021
|
try {
|
|
947
1022
|
const written = await seedSettings({
|
|
948
1023
|
apiDir,
|
|
949
1024
|
mongoUri,
|
|
950
1025
|
userId: creds.userId,
|
|
951
|
-
...(chosenModels ? { models: chosenModels } : {}),
|
|
1026
|
+
...(chosenModels && !skipModels ? { models: chosenModels } : {}),
|
|
1027
|
+
skipModels,
|
|
952
1028
|
});
|
|
953
|
-
|
|
1029
|
+
// #301: the keys typed for the servers, saved through the API now that their rows exist.
|
|
1030
|
+
// Reported by host, never by value.
|
|
1031
|
+
if (!skipModels && chosenModels?.keys?.length) {
|
|
1032
|
+
for (const r of await saveSetupKeys({ workerKey: creds.workerKey, models: chosenModels })) {
|
|
1033
|
+
console.log(r.ok
|
|
1034
|
+
? " " + cyan("✓ API key stored") + dim(` for ${r.host} — the server's default key`)
|
|
1035
|
+
: " " + yellow("⚠ could not store the key") + dim(` for ${r.host} — add it in /model with ctrl+k`));
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
if (skipModels) {
|
|
1039
|
+
console.log("");
|
|
1040
|
+
console.log(" " + cyan("✓ Model setup skipped") + dim(loadPreset
|
|
1041
|
+
? " — the preset picker opens as soon as gu starts"
|
|
1042
|
+
: " — set them any time with /model (or /load-presets)"));
|
|
1043
|
+
} else if (Object.keys(written).length === 0) {
|
|
954
1044
|
console.log(" " + cyan("✓ Models and RAG") + dim(" — already configured, left alone"));
|
|
955
1045
|
} else {
|
|
956
1046
|
console.log(
|
|
@@ -997,5 +1087,7 @@ export async function runClientExpertSetup({ ask }) {
|
|
|
997
1087
|
dim("\n machine. The API restarts with `gu-cli api start`; its log is") +
|
|
998
1088
|
dim(`\n ${API_LOG_FILE}\n`)
|
|
999
1089
|
);
|
|
1000
|
-
|
|
1090
|
+
// #301: the caller opens /load-presets once the terminal exists — the picker, the plan and the
|
|
1091
|
+
// confirmation are that command's, not rebuilt here.
|
|
1092
|
+
return { complete: true, stoppedAt: null, ...(loadPreset ? { loadPreset: true } : {}) };
|
|
1001
1093
|
}
|
package/gu-repl.mjs
CHANGED
|
@@ -96,6 +96,7 @@ import {
|
|
|
96
96
|
physicalRows,
|
|
97
97
|
inputPhysicalRows,
|
|
98
98
|
buildFooterRows,
|
|
99
|
+
pasteHint,
|
|
99
100
|
footerDrawSeq,
|
|
100
101
|
footerEraseSeq,
|
|
101
102
|
clearBlockSeq,
|
|
@@ -632,6 +633,10 @@ function reloadWorkerEnv() {
|
|
|
632
633
|
// first-run branch also runs one — without this the same walkthrough printed twice in a single
|
|
633
634
|
// run, MongoDB instructions and all. Reported live on Windows 11.
|
|
634
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;
|
|
635
640
|
|
|
636
641
|
if (!workerKey || !apiBase) {
|
|
637
642
|
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
@@ -728,7 +733,12 @@ if (!workerKey || !apiBase) {
|
|
|
728
733
|
await saveState({ ...(await loadState()), chosenMode: mode0 ?? "client" });
|
|
729
734
|
if (mode0 === "client-expert") {
|
|
730
735
|
clientExpertRanThisProcess = true;
|
|
731
|
-
|
|
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;
|
|
732
742
|
// STOP when setup cannot go further.
|
|
733
743
|
//
|
|
734
744
|
// This used to fall through to the hosted sign-in, on the theory that "this machine
|
|
@@ -2376,6 +2386,9 @@ const footerRows = () =>
|
|
|
2376
2386
|
identity: identityLineText(),
|
|
2377
2387
|
token: tokenLineText(),
|
|
2378
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() }),
|
|
2379
2392
|
// Its own row (#54), sized to the window so the fix it names is never the part cut off.
|
|
2380
2393
|
notice: imageNotice
|
|
2381
2394
|
? orange("⚠ " + fitImageNotice(imageNotice, (process.stdout.columns || 80) - visibleLen(GUTTER) - 1))
|
|
@@ -5231,15 +5244,35 @@ async function reconfigureOllamaServer() {
|
|
|
5231
5244
|
|
|
5232
5245
|
/** Load a box's models THROUGH THE PROXY (#160) — a bare fetch is what fails on a restricted
|
|
5233
5246
|
* network, and it fails as "no models", which is the wrong story entirely. */
|
|
5234
|
-
const load = async (n) => {
|
|
5247
|
+
const load = async (n, key = "") => {
|
|
5235
5248
|
const res = await withPendingStatus(`loading models from ${n.host}`, () =>
|
|
5236
|
-
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
|
+
})
|
|
5237
5254
|
);
|
|
5238
5255
|
// The status is kept: a 404 here means "answered, but not an Ollama" (#43), not "unreachable".
|
|
5239
5256
|
if (!res.ok) throw Object.assign(new Error(`HTTP ${res.status}`), { httpStatus: res.status });
|
|
5240
5257
|
return am.parseOllamaTags(await res.json().catch(() => null));
|
|
5241
5258
|
};
|
|
5242
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
|
+
|
|
5243
5276
|
/** Ask for one server and load it. Returns {normalized, models} or null (declined/failed). */
|
|
5244
5277
|
const askServer = async (label, dflt, { require = false, short = "server", role = "coder" } = {}) => {
|
|
5245
5278
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
@@ -5251,13 +5284,15 @@ async function reconfigureOllamaServer() {
|
|
|
5251
5284
|
// — normalizeOllamaUrl adds it — and displayUrl round-trips, so what is shown can be fed
|
|
5252
5285
|
// straight back as the default.
|
|
5253
5286
|
console.log(dim(` ${label} — Enter keeps ${os.displayUrl(dflt)}`));
|
|
5287
|
+
console.log(dim(` ${hint()}`));
|
|
5254
5288
|
// THE ROW MUST SAY WHICH SERVER IT IS ASKING FOR. It said a bare "server:" — chosen to
|
|
5255
5289
|
// fit the frozen row, which traded away the one thing the label was for. Two questions
|
|
5256
5290
|
// in a row, both reading "server:", and the reader cannot tell which one they are
|
|
5257
5291
|
// answering (reported: "it should say 'Agent + Embedder server' then 'Coding Server',
|
|
5258
5292
|
// it only says 'server:'"). `short` is the role in the fewest words that still name it.
|
|
5259
5293
|
const typed = (await ask(` ${short}: `)).trim();
|
|
5260
|
-
|
|
5294
|
+
// "s" is the advertised skip (#301); the older words still work.
|
|
5295
|
+
if (ui.readStepAnswer(typed).kind === "skip") return null;
|
|
5261
5296
|
// EVERY READING OF A BARE HOSTNAME, in order. Typing "ollama2.gomarsic.cc" means
|
|
5262
5297
|
// https://…:443 behind a proxy, while "192.168.1.40" means http://…:11434 — one guess
|
|
5263
5298
|
// is right for one of them and produces a timeout that looks like a dead server for the
|
|
@@ -5270,15 +5305,33 @@ async function reconfigureOllamaServer() {
|
|
|
5270
5305
|
let n = null;
|
|
5271
5306
|
let models = null;
|
|
5272
5307
|
let lastErr = null;
|
|
5273
|
-
|
|
5274
|
-
|
|
5275
|
-
|
|
5276
|
-
|
|
5277
|
-
|
|
5278
|
-
|
|
5279
|
-
|
|
5280
|
-
|
|
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
|
+
}
|
|
5281
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;
|
|
5282
5335
|
}
|
|
5283
5336
|
if (!n) {
|
|
5284
5337
|
if (lastErr?.httpStatus === 404) {
|
|
@@ -5305,7 +5358,7 @@ async function reconfigureOllamaServer() {
|
|
|
5305
5358
|
const need = os.checkRequired(models);
|
|
5306
5359
|
if (!need.ok) {
|
|
5307
5360
|
for (const l of os.describeNotReady(n.host, need.missing)) console.log(yellow(` ${l}`));
|
|
5308
|
-
console.log(dim(" (or type
|
|
5361
|
+
console.log(dim(" (or type s to skip — you can set it later with /model)"));
|
|
5309
5362
|
continue;
|
|
5310
5363
|
}
|
|
5311
5364
|
console.log(dim(` ✓ ${n.host} has the ${os.REQUIRED_AGENT_PREFIX} family and the embedder`));
|
|
@@ -5328,10 +5381,10 @@ async function reconfigureOllamaServer() {
|
|
|
5328
5381
|
if (agentOnProvider) {
|
|
5329
5382
|
const host = hostOf(String(settings.agentModelUrl ?? ""));
|
|
5330
5383
|
console.log(dim(` The agent model is on ${host} (OpenAI-compatible) — kept as it is; change it with /agent-model.`));
|
|
5331
|
-
console.log(dim(" Enter keeps the current address
|
|
5384
|
+
console.log(dim(" Enter keeps the current address."));
|
|
5332
5385
|
} else {
|
|
5333
5386
|
console.log(dim(" TWO servers may be used: the agent + embedder on one, the coder on another."));
|
|
5334
|
-
console.log(dim(" Enter keeps the current address
|
|
5387
|
+
console.log(dim(" Enter keeps the current address."));
|
|
5335
5388
|
agent = await askServer("Agent + embedding server", curAgent, {
|
|
5336
5389
|
require: true,
|
|
5337
5390
|
short: "agent + embed server",
|
|
@@ -5476,6 +5529,32 @@ async function reconfigureOllamaServer() {
|
|
|
5476
5529
|
console.log(GUTTER + dim("if this machine's local API is old, restart it with `gu api restart`.\n"));
|
|
5477
5530
|
return;
|
|
5478
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
|
+
}
|
|
5479
5558
|
// THE OLLAMA CLOUD NEEDS ITS OWN KEY. Reported: choosing https://ollama.com here listed its 20 models
|
|
5480
5559
|
// (that list is public) and saved, without a key — and the next turn failed "REJECTED the coding API
|
|
5481
5560
|
// key", having borrowed an unrelated sk-… key. Asked for now, bound to the row just saved.
|
|
@@ -10869,8 +10948,18 @@ async function main() {
|
|
|
10869
10948
|
// recorded, so once the choice was saved this block was skipped and the plan sat on disk
|
|
10870
10949
|
// untouched. Picking it up here is what makes the sentence true.
|
|
10871
10950
|
const { runClientExpertSetup } = await import("./client-expert-run.mjs");
|
|
10872
|
-
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
|
+
});
|
|
10873
10961
|
if (setup?.complete) reloadWorkerEnv();
|
|
10962
|
+
if (setup?.loadPreset) openPresetsAtStart = true;
|
|
10874
10963
|
}
|
|
10875
10964
|
} catch {
|
|
10876
10965
|
/* never block the REPL on the mode question — the banner still shows what is configured */
|
|
@@ -11621,6 +11710,17 @@ async function main() {
|
|
|
11621
11710
|
// rows it pushes up are real output rather than half-drawn setup.
|
|
11622
11711
|
syncBarPinning();
|
|
11623
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
|
+
|
|
11624
11724
|
for (;;) {
|
|
11625
11725
|
stopPendingStatus(); // nothing from a previous turn may still be animating (task #140)
|
|
11626
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
|
+
}
|
package/ollama-setup.mjs
CHANGED
|
@@ -21,6 +21,14 @@
|
|
|
21
21
|
import { isLikelyEmbedder } from "./agent-model-command.mjs";
|
|
22
22
|
import { EMBED_CHOICES } from "./embed-model-command.mjs";
|
|
23
23
|
import { isCancelWord } from "./coding-provider-setup.mjs";
|
|
24
|
+
import {
|
|
25
|
+
SKIP_ALL,
|
|
26
|
+
createHinter,
|
|
27
|
+
keyConfirmation,
|
|
28
|
+
needsKeyAfterStatus,
|
|
29
|
+
readStepAnswer,
|
|
30
|
+
shouldAskKey,
|
|
31
|
+
} from "./model-setup-ui.mjs";
|
|
24
32
|
|
|
25
33
|
/** Ollama's native port, used when someone types a bare host or an IP with no port. */
|
|
26
34
|
export const OLLAMA_DEFAULT_PORT = 11434;
|
|
@@ -649,21 +657,83 @@ export async function askOllamaSetup(
|
|
|
649
657
|
// Injected so the tests can drive it; defaults to a real request with a short deadline,
|
|
650
658
|
// because this runs while someone is waiting at a prompt.
|
|
651
659
|
probeStatus = defaultProbeStatus,
|
|
660
|
+
// #301: the MASKED prompt for an API key. Without one no key is ever asked — a secret is
|
|
661
|
+
// never read through a prompt that echoes it.
|
|
662
|
+
askSecret = null,
|
|
663
|
+
// #301: the hint under each model prompt — full the first time, short after.
|
|
664
|
+
hint = createHinter(),
|
|
652
665
|
} = {},
|
|
653
666
|
) {
|
|
667
|
+
// #301: every key typed for a server, by its HOSTNAME. "" = asked, left empty. Not by URL: the
|
|
668
|
+
// key is asked before listing, for the first reading of what was typed, and a different reading
|
|
669
|
+
// (another port or scheme) may be the one that answers.
|
|
670
|
+
const keys = new Map();
|
|
671
|
+
const SKIPPED = { skipped: true, keys: [] };
|
|
672
|
+
// #301: a step SKIPPED ON PURPOSE, as against a server that never answered. Nothing chosen
|
|
673
|
+
// because of a skip means "leave the models unset"; nothing chosen because nothing answered
|
|
674
|
+
// stays null, and the caller falls back to the shared box as it always has.
|
|
675
|
+
let declined = false;
|
|
676
|
+
// Dim, like the other guidance lines under a question; one line, above the prompt it explains.
|
|
677
|
+
const say = (h) => log(` \x1b[2m${h}\x1b[0m`);
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* The key question for a TYPED address, before its models are listed (so a server that needs
|
|
681
|
+
* one can list them). Returns the key, "" for none, or SKIP_ALL.
|
|
682
|
+
*/
|
|
683
|
+
const askKeyFor = async (host) => {
|
|
684
|
+
if (keys.has(host)) return keys.get(host); // same server for both roles → asked once
|
|
685
|
+
if (typeof askSecret !== "function") return "";
|
|
686
|
+
say(hint());
|
|
687
|
+
const raw = await askSecret(` API key for ${host} (Enter for none): `);
|
|
688
|
+
if (raw === SKIP_ALL) return SKIP_ALL;
|
|
689
|
+
const key = String(raw ?? "").trim();
|
|
690
|
+
keys.set(host, key);
|
|
691
|
+
log(` ${keyConfirmation(host, key)}`);
|
|
692
|
+
return key;
|
|
693
|
+
};
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* List the models of what was typed (or the default), asking for a key when the address is
|
|
697
|
+
* public or the server refuses without one. { ok, normalized, models } | { failed } | SKIP_ALL.
|
|
698
|
+
*/
|
|
699
|
+
const loadServer = async (typed, fallbackUrl, reuse) => {
|
|
700
|
+
const raw = typed || fallbackUrl;
|
|
701
|
+
const first = urlCandidates(raw)[0];
|
|
702
|
+
if (reuse && first && reuse.normalized.url === first.url) return reuse;
|
|
703
|
+
let key = "";
|
|
704
|
+
if (first && shouldAskKey({ typed, host: first.host })) {
|
|
705
|
+
key = await askKeyFor(first.host);
|
|
706
|
+
if (key === SKIP_ALL) return SKIP_ALL;
|
|
707
|
+
}
|
|
708
|
+
let tried = await loadFirstThatAnswers(raw, fetchTags, log, key);
|
|
709
|
+
// A server that refused for want of credentials: ask now, whatever the address looked like,
|
|
710
|
+
// and list again with the key. Once — a second refusal is a wrong key, reported as such.
|
|
711
|
+
if (tried?.failed && needsKeyAfterStatus(tried.status) && !key && first) {
|
|
712
|
+
log(` ${first.host} needs an API key to list its models.`);
|
|
713
|
+
keys.delete(first.host);
|
|
714
|
+
key = await askKeyFor(first.host);
|
|
715
|
+
if (key === SKIP_ALL) return SKIP_ALL;
|
|
716
|
+
if (key) tried = await loadFirstThatAnswers(raw, fetchTags, log, key);
|
|
717
|
+
}
|
|
718
|
+
return tried;
|
|
719
|
+
};
|
|
720
|
+
|
|
654
721
|
// ---- SERVER 1: the agent model and the embedder -------------------------------------
|
|
655
722
|
// These two are REQUIRED and not a free choice (see REQUIRED_AGENT_MODEL). A box without
|
|
656
723
|
// them is "not ready" rather than "wrong": it may still be a perfectly good coder host, which
|
|
657
724
|
// is why this refusal does not end the flow.
|
|
658
725
|
let agent = null;
|
|
659
726
|
for (let attempt = 0; attempt < attempts && !agent; attempt++) {
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
727
|
+
say(hint());
|
|
728
|
+
const a = readStepAnswer(await ask(` Agent + embedding server [${displayUrl(defaultUrl)}]: `));
|
|
729
|
+
if (a.kind === "all") return SKIPPED;
|
|
730
|
+
if (a.kind === "skip") { declined = true; break; }
|
|
731
|
+
const tried = await loadServer(a.text, defaultUrl, null);
|
|
732
|
+
if (tried === SKIP_ALL) return SKIPPED;
|
|
663
733
|
if (!tried || tried.failed) {
|
|
664
734
|
// One explanation, the best one available: the provider message when we recognise the
|
|
665
735
|
// address, otherwise the reachability advice we were going to give anyway.
|
|
666
|
-
const explained = await explainIfOpenAi(
|
|
736
|
+
const explained = await explainIfOpenAi(a.text || defaultUrl, probeStatus, log, describeOpenAiAgent);
|
|
667
737
|
if (!explained) for (const l of tried?.failed ?? []) log(` ${l}`);
|
|
668
738
|
continue;
|
|
669
739
|
}
|
|
@@ -676,7 +746,9 @@ export async function askOllamaSetup(
|
|
|
676
746
|
const need = checkRequired(models);
|
|
677
747
|
if (!need.ok) {
|
|
678
748
|
for (const l of describeNotReady(normalized.host, need.missing)) log(` ${l}`);
|
|
679
|
-
|
|
749
|
+
// #301: this used to tell people to press Enter to skip — but Enter takes the DEFAULT
|
|
750
|
+
// server. "s" is the skip.
|
|
751
|
+
log(" (or type s to skip — you can set it later with /model)");
|
|
680
752
|
continue;
|
|
681
753
|
}
|
|
682
754
|
log(` ✓ ${normalized.host} has the ${REQUIRED_AGENT_PREFIX} family and the embedder`);
|
|
@@ -690,18 +762,21 @@ export async function askOllamaSetup(
|
|
|
690
762
|
const coderDefault = agent?.normalized?.url ?? defaultUrl;
|
|
691
763
|
let coder = null;
|
|
692
764
|
for (let attempt = 0; attempt < attempts && !coder; attempt++) {
|
|
693
|
-
|
|
694
|
-
|
|
765
|
+
say(hint());
|
|
766
|
+
const a = readStepAnswer(await ask(` Coding-model server [${displayUrl(coderDefault)}]: `));
|
|
767
|
+
if (a.kind === "all") return SKIPPED;
|
|
768
|
+
if (a.kind === "skip") { declined = true; break; }
|
|
695
769
|
// Same box as the agent server → reuse the list we already have rather than fetching twice.
|
|
696
|
-
const same = normalizeOllamaUrl(
|
|
770
|
+
const same = normalizeOllamaUrl(a.text || coderDefault);
|
|
697
771
|
const tried =
|
|
698
772
|
agent && same && same.url === agent.normalized.url
|
|
699
773
|
? { normalized: same, models: agent.models }
|
|
700
|
-
: await
|
|
774
|
+
: await loadServer(a.text, coderDefault, agent);
|
|
775
|
+
if (tried === SKIP_ALL) return SKIPPED;
|
|
701
776
|
if (!tried || tried.failed) {
|
|
702
777
|
// One explanation, the best one available: the provider message when we recognise the
|
|
703
778
|
// address, otherwise the reachability advice we were going to give anyway.
|
|
704
|
-
const explained = await explainIfOpenAi(
|
|
779
|
+
const explained = await explainIfOpenAi(a.text || coderDefault, probeStatus, log, describeOpenAiCoder);
|
|
705
780
|
if (!explained) for (const l of tried?.failed ?? []) log(` ${l}`);
|
|
706
781
|
continue;
|
|
707
782
|
}
|
|
@@ -718,7 +793,7 @@ export async function askOllamaSetup(
|
|
|
718
793
|
// A real answer, not a failure. The machine finishes setup unconfigured, the bar shows the
|
|
719
794
|
// roles as unset, and the first question says so instead of hanging — which is the entire
|
|
720
795
|
// reason declining is allowed to be this easy.
|
|
721
|
-
if (!agent && !coder) return null;
|
|
796
|
+
if (!agent && !coder) return declined ? SKIPPED : null;
|
|
722
797
|
|
|
723
798
|
// ---- PICK THE MODELS -----------------------------------------------------------------
|
|
724
799
|
// THE AGENT IS CHOSEN, not assumed (user, 2026-09-06). The gate above proved the family is
|
|
@@ -729,7 +804,6 @@ export async function askOllamaSetup(
|
|
|
729
804
|
// Seed the embedder the box ACTUALLY has: the shipped default when present, else whichever
|
|
730
805
|
// accepted embedder passed the gate — a qwen3-embedding-only box must not be told it now
|
|
731
806
|
// embeds with a model it never had.
|
|
732
|
-
const embed = agent ? defaultEmbedModel(agent.models) || REQUIRED_EMBED_MODEL : null;
|
|
733
807
|
let chat = "";
|
|
734
808
|
if (agent) {
|
|
735
809
|
const cands = agentCandidates(agent.models);
|
|
@@ -741,13 +815,18 @@ export async function askOllamaSetup(
|
|
|
741
815
|
log(" AGENT model — routes every turn and writes the plain replies");
|
|
742
816
|
for (const row of renderModelMenu(cands, { defaultModel: agentDefault, formatSize })) log(row);
|
|
743
817
|
for (let t = 0; t < attempts && !chat; t++) {
|
|
744
|
-
|
|
745
|
-
|
|
818
|
+
say(hint());
|
|
819
|
+
const a = readStepAnswer(await ask(` agent [${agentDefault}]: `));
|
|
820
|
+
if (a.kind === "all") return SKIPPED;
|
|
821
|
+
// #301: "s" here skips the AGENT role — its server was chosen, its model is not.
|
|
822
|
+
if (a.kind === "skip") { agent = null; break; }
|
|
823
|
+
chat = parseMenuChoice(a.text, cands, agentDefault) ?? "";
|
|
746
824
|
if (!chat) log(` no model called that — type its number, or Enter for ${agentDefault}`);
|
|
747
825
|
}
|
|
748
|
-
chat = chat || agentDefault;
|
|
826
|
+
if (agent) chat = chat || agentDefault;
|
|
749
827
|
}
|
|
750
828
|
}
|
|
829
|
+
const embed = agent ? defaultEmbedModel(agent.models) || REQUIRED_EMBED_MODEL : null;
|
|
751
830
|
let code = "";
|
|
752
831
|
let extras = [];
|
|
753
832
|
if (coder) {
|
|
@@ -756,20 +835,37 @@ export async function askOllamaSetup(
|
|
|
756
835
|
log(" CODING model — writes the code; worth waiting for");
|
|
757
836
|
for (const row of renderModelMenu(coder.models, { defaultModel: roles.code, formatSize })) log(row);
|
|
758
837
|
for (let t = 0; t < attempts && !code; t++) {
|
|
759
|
-
|
|
760
|
-
|
|
838
|
+
say(hint());
|
|
839
|
+
const a = readStepAnswer(await ask(` code [${roles.code}]: `));
|
|
840
|
+
if (a.kind === "all") return SKIPPED;
|
|
841
|
+
if (a.kind === "skip") { coder = null; break; }
|
|
842
|
+
code = parseMenuChoice(a.text, coder.models, roles.code) ?? "";
|
|
761
843
|
if (!code) log(` no model called that — type its number, or Enter for ${roles.code}`);
|
|
762
844
|
}
|
|
763
|
-
code = code || roles.code;
|
|
845
|
+
if (coder) code = code || roles.code;
|
|
764
846
|
|
|
765
|
-
const rest = coder.models.filter((m) => !isLikelyEmbedder(m) && m.model !== code);
|
|
847
|
+
const rest = coder ? coder.models.filter((m) => !isLikelyEmbedder(m) && m.model !== code) : [];
|
|
766
848
|
if (rest.length) {
|
|
767
849
|
log("");
|
|
768
850
|
log(" Also offer in /model? (numbers separated by commas, or Enter for none)");
|
|
769
851
|
for (const row of renderModelMenu(rest, { formatSize })) log(row);
|
|
770
|
-
|
|
852
|
+
say(hint());
|
|
853
|
+
const a = readStepAnswer(await ask(" also: "));
|
|
854
|
+
if (a.kind === "all") return SKIPPED;
|
|
855
|
+
extras = a.kind === "skip" ? [] : parseMultiChoice(a.text, rest);
|
|
771
856
|
}
|
|
772
857
|
}
|
|
858
|
+
// Both roles skipped at the model menus: as much a decision as skipping both servers.
|
|
859
|
+
if (!agent && !coder) return SKIPPED;
|
|
860
|
+
|
|
861
|
+
// #301: the keys typed, only for the servers actually used, never the empty ones — each under
|
|
862
|
+
// the URL that ANSWERED, which is the one the settings rows are written with.
|
|
863
|
+
const used = [agent?.normalized, coder?.normalized].filter(Boolean);
|
|
864
|
+
const keyList = [];
|
|
865
|
+
for (const [host, key] of keys) {
|
|
866
|
+
const n = key && used.find((u) => u.host === host);
|
|
867
|
+
if (n && !keyList.some((k) => k.url === n.url)) keyList.push({ url: n.url, key });
|
|
868
|
+
}
|
|
773
869
|
|
|
774
870
|
// HALF-CONFIGURED IS A REAL STATE and must be representable. Someone with an agent box but no
|
|
775
871
|
// coder yet, or a coder but no agent box, has made progress worth keeping — forcing both or
|
|
@@ -786,6 +882,7 @@ export async function askOllamaSetup(
|
|
|
786
882
|
...(embed ? { embed } : {}),
|
|
787
883
|
altCoders: (extras ?? []).filter((n) => n !== code),
|
|
788
884
|
ready: Boolean(agent && coder),
|
|
885
|
+
...(keyList.length ? { keys: keyList } : {}),
|
|
789
886
|
};
|
|
790
887
|
}
|
|
791
888
|
|
|
@@ -848,7 +945,7 @@ async function explainIfOpenAi(raw, probeStatus, log, describe) {
|
|
|
848
945
|
return true;
|
|
849
946
|
}
|
|
850
947
|
|
|
851
|
-
async function loadFirstThatAnswers(raw, fetchTags, log) {
|
|
948
|
+
async function loadFirstThatAnswers(raw, fetchTags, log, apiKey = "") {
|
|
852
949
|
const candidates = urlCandidates(raw);
|
|
853
950
|
if (!candidates.length) {
|
|
854
951
|
return { failed: ["that is not a server address — try 192.168.1.40, ollama.local, or a full URL"] };
|
|
@@ -856,7 +953,10 @@ async function loadFirstThatAnswers(raw, fetchTags, log) {
|
|
|
856
953
|
let last = null;
|
|
857
954
|
for (const normalized of candidates) {
|
|
858
955
|
try {
|
|
859
|
-
|
|
956
|
+
// #301: the key goes along only when there is one — a keyless call stays byte-identical.
|
|
957
|
+
const models = apiKey
|
|
958
|
+
? await fetchTags(tagsUrlFor(normalized), { apiKey })
|
|
959
|
+
: await fetchTags(tagsUrlFor(normalized));
|
|
860
960
|
// Say WHICH reading answered, but only when we had to guess — otherwise it is noise
|
|
861
961
|
// repeating what the user just typed.
|
|
862
962
|
if (candidates.length > 1) log(` using ${displayUrl(normalized.url)}`);
|
|
@@ -869,6 +969,11 @@ async function loadFirstThatAnswers(raw, fetchTags, log) {
|
|
|
869
969
|
// NOT PRINTED HERE ANY MORE. "check that Ollama is running there" is exactly the wrong thing
|
|
870
970
|
// to say about a correct address for a hosted provider, and the caller is the only one that
|
|
871
971
|
// knows whether it found a better explanation. It prints one message or the other, never both.
|
|
872
|
-
|
|
972
|
+
// #301: an HTTP status (401/403 = needs a key) travels with the failure so the caller can ask.
|
|
973
|
+
const status = Number(last?.status ?? last?.httpStatus) || 0;
|
|
974
|
+
if (needsKeyAfterStatus(status)) {
|
|
975
|
+
return { status, failed: [`${candidates[0].host} refused the request (HTTP ${status}) — ${apiKey ? "that key was not accepted" : "it needs an API key"}.`] };
|
|
976
|
+
}
|
|
977
|
+
return { status, failed: [`could not reach ${candidates[0].host}: ${networkReason(last)}`,
|
|
873
978
|
"check the address, or that Ollama is running there and listening on the network."] };
|
|
874
979
|
}
|
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.769",
|
|
4
4
|
"description": "Gu CLI — the gu terminal plus the local worker that runs agent / OCR / PDF / embedding jobs on your Mac (Ollama / MLX / OpenAI-compatible).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -50,6 +50,8 @@
|
|
|
50
50
|
"gu-cli.mjs",
|
|
51
51
|
"gu-repl.mjs",
|
|
52
52
|
"step-tokens.mjs",
|
|
53
|
+
"model-setup-ui.mjs",
|
|
54
|
+
"setup-prompts.mjs",
|
|
53
55
|
"terminal-hover.mjs",
|
|
54
56
|
"terminal-layout.mjs",
|
|
55
57
|
"terminal-viewport.mjs",
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE FIRST-RUN PROMPTS THAT PLAIN rl.question CANNOT BE (#301, #300).
|
|
3
|
+
*
|
|
4
|
+
* First-run setup runs before the REPL exists, on its own readline (gu-repl.mjs `rl0`), and every
|
|
5
|
+
* question there was `rl0.question` — which echoes. Two things need more than that:
|
|
6
|
+
*
|
|
7
|
+
* · ESC. The model questions offer "Esc = skip all model setup". Esc submits nothing in readline,
|
|
8
|
+
* so the pending question is cancelled through its AbortSignal and resolved with SKIP_ALL.
|
|
9
|
+
* · A SECRET. An API key, or the MongoDB password (#300), must never reach the screen or the
|
|
10
|
+
* scrollback. Readline's echo and line refresh are muted for the question, one "•" per
|
|
11
|
+
* character is drawn instead (the only proof a keystroke registered), and the answer is taken
|
|
12
|
+
* back out of readline's history — which keeps every submitted line, so the secret would
|
|
13
|
+
* otherwise come back in plain text on the next press of Up.
|
|
14
|
+
*
|
|
15
|
+
* The REPL has its own askSecret with the same guarantees (task #127/#191); this is the version for
|
|
16
|
+
* the one stretch of the program that runs before it.
|
|
17
|
+
*/
|
|
18
|
+
import { SKIP_ALL, isEscapeKey } from "./model-setup-ui.mjs";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* One question on `rl`. Options: `secret` (mask + keep out of history), `esc` (Esc → SKIP_ALL).
|
|
22
|
+
* `style` decorates the label (the accent colour). Resolves with the answer string, or SKIP_ALL.
|
|
23
|
+
*/
|
|
24
|
+
export function askOn(rl, q, { secret = false, esc = false, style = (s) => s, input = process.stdin, output = process.stdout } = {}) {
|
|
25
|
+
return new Promise((resolve) => {
|
|
26
|
+
const shown = style(q);
|
|
27
|
+
const ac = new AbortController();
|
|
28
|
+
const origWrite = rl._writeToOutput;
|
|
29
|
+
const origRefresh = rl._refreshLine;
|
|
30
|
+
let done = false;
|
|
31
|
+
|
|
32
|
+
const finish = (value) => {
|
|
33
|
+
if (done) return;
|
|
34
|
+
done = true;
|
|
35
|
+
input.removeListener?.("keypress", onKey);
|
|
36
|
+
if (secret) {
|
|
37
|
+
rl._writeToOutput = origWrite;
|
|
38
|
+
rl._refreshLine = origRefresh;
|
|
39
|
+
if (Array.isArray(rl.history) && value && rl.history[0] === value) rl.history.shift();
|
|
40
|
+
rl.historyIndex = -1;
|
|
41
|
+
}
|
|
42
|
+
// A muted Enter printed no newline, and an Esc left the caret on the prompt's row.
|
|
43
|
+
if (secret || value === SKIP_ALL) output.write("\n");
|
|
44
|
+
resolve(value);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// Runs AFTER readline's own keypress handler (registered when rl was created), so rl.line is
|
|
48
|
+
// already the updated text when the bullets are drawn.
|
|
49
|
+
function onKey(_s, key) {
|
|
50
|
+
if (done) return;
|
|
51
|
+
if (esc && isEscapeKey(key)) {
|
|
52
|
+
ac.abort();
|
|
53
|
+
// The cancelled question leaves what was typed in rl.line; the next question must not
|
|
54
|
+
// start with it.
|
|
55
|
+
rl.line = "";
|
|
56
|
+
rl.cursor = 0;
|
|
57
|
+
finish(SKIP_ALL);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (secret && output.isTTY) {
|
|
61
|
+
output.write(`\r\x1b[2K${shown}${"•".repeat(String(rl.line ?? "").length)}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
input.on?.("keypress", onKey);
|
|
66
|
+
// question() draws the label synchronously — mute only AFTER, or the label is muted too.
|
|
67
|
+
rl.question(shown, { signal: ac.signal }, (a) => finish(String(a ?? "")));
|
|
68
|
+
if (secret) {
|
|
69
|
+
rl._writeToOutput = () => {};
|
|
70
|
+
rl._refreshLine = () => {};
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The three readers setup takes: plain, Esc-aware (model questions), masked (keys, passwords). */
|
|
76
|
+
export function setupPrompts(rl, { style = (s) => s, input = process.stdin, output = process.stdout } = {}) {
|
|
77
|
+
return {
|
|
78
|
+
ask: (q) => askOn(rl, q, { style, input, output }),
|
|
79
|
+
askStep: (q) => askOn(rl, q, { esc: true, style, input, output }),
|
|
80
|
+
askSecret: (q) => askOn(rl, q, { secret: true, esc: true, style, input, output }),
|
|
81
|
+
};
|
|
82
|
+
}
|
package/terminal-layout.mjs
CHANGED
|
@@ -468,6 +468,27 @@ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
|
468
468
|
* `identity` is optional so callers that have nothing to put there (and the tests that predate
|
|
469
469
|
* it) keep producing the same rows as before.
|
|
470
470
|
*/
|
|
471
|
+
/**
|
|
472
|
+
* HOW TO PASTE, for this platform (issue #67). Two different things share the word: TEXT is pasted
|
|
473
|
+
* by the terminal app (gu only sees the result), an IMAGE by gu's own keys (#284).
|
|
474
|
+
*
|
|
475
|
+
* macOS text ⌘V image ctrl+v — a terminal's ⌘V sends nothing for an image
|
|
476
|
+
* Windows text ctrl+v image alt+v — Windows Terminal keeps ctrl+v for itself
|
|
477
|
+
* Linux text ctrl+shift+v image ctrl+v
|
|
478
|
+
*
|
|
479
|
+
* The image half only when the coder can take one (a Claude coder); offering a key that then
|
|
480
|
+
* answers "images need a Claude coder" would be a hint that leads to a warning.
|
|
481
|
+
*/
|
|
482
|
+
export function pasteHint({ platform = process.platform, images = false } = {}) {
|
|
483
|
+
const text = platform === "darwin" ? "⌘V" : platform === "win32" ? "ctrl+v" : "ctrl+shift+v";
|
|
484
|
+
if (!images) return `${text} to paste`;
|
|
485
|
+
const image = platform === "win32" ? "alt+v" : "ctrl+v";
|
|
486
|
+
return `${text} paste · ${image} image`;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** At least this many spaces between the mode text and the hint, or the hint is left out. */
|
|
490
|
+
const HINT_MIN_GAP = 3;
|
|
491
|
+
|
|
471
492
|
export function buildFooterRows({
|
|
472
493
|
width,
|
|
473
494
|
gutter = " ",
|
|
@@ -475,16 +496,25 @@ export function buildFooterRows({
|
|
|
475
496
|
identity = "",
|
|
476
497
|
token = "",
|
|
477
498
|
mode = "",
|
|
499
|
+
// Right-aligned on the mode row (issue #67), dim. DROPPED WHOLE when it does not fit: every
|
|
500
|
+
// footer row must stay one physical row (#109/#132/#138), and half a key name is worse than none.
|
|
501
|
+
modeHint = "",
|
|
478
502
|
notice = "",
|
|
479
503
|
missing = "",
|
|
480
504
|
}) {
|
|
481
505
|
const w = Math.max(20, width || 80);
|
|
506
|
+
const modeLeft = gutter + mode;
|
|
507
|
+
const hintGap = modeHint ? w - 1 - visibleLen(modeLeft) - visibleLen(modeHint) : -1;
|
|
508
|
+
const modeRow =
|
|
509
|
+
hintGap >= HINT_MIN_GAP
|
|
510
|
+
? modeLeft + " ".repeat(hintGap) + dim(modeHint)
|
|
511
|
+
: clipVisible(modeLeft, w - 1);
|
|
482
512
|
return [
|
|
483
513
|
dim("─".repeat(w)),
|
|
484
514
|
...(rag ? [clipVisible(gutter + rag, w - 1)] : []),
|
|
485
515
|
...(identity ? [clipVisible(gutter + identity, w - 1)] : []),
|
|
486
516
|
clipVisible(gutter + token, w - 1),
|
|
487
|
-
|
|
517
|
+
modeRow,
|
|
488
518
|
// A passing notice (the image-paste warning, #54) gets its OWN row, under the mode row — it
|
|
489
519
|
// used to be appended to that row and was clipped at the right edge ("…pick one with /mo").
|
|
490
520
|
// Present only while it shows; paintBar re-cuts the frozen zone when the count changes.
|