@tiens.nguyen/gu-cli 1.0.869 → 1.0.871

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/gu-repl.mjs CHANGED
@@ -157,6 +157,8 @@ import { globalPrefixOf } from "./install-path.mjs";
157
157
  import { indentBlock, commandBlockRows } from "./terminal-layout.mjs"; // every line of an Allow-prompt detail indented (#189); wrapped + collapsible command (#204)
158
158
  import { firstGuOnPath, pathGuMismatch, guOnPathIsCurrent, updateAttemptRecord, repeatUpdateNotice } from "./update-verify.mjs";
159
159
  import { embedNotice, embedState, setupNoticeRows } from "./embed-model-command.mjs";
160
+ import { HINTS, pickHint, hintRow, hintFits, HINT_AFTER_SECONDS } from "./hints.mjs";
161
+ import { normalizeQuick, quickReady, quickTarget, quickBarHint, quickWarning, QUICK_MAX } from "./quick-models.mjs";
160
162
  import {
161
163
  MODELFALLBACK_PREFIX, fallbackAfterTurn, fallbackHistoryLine, fallbackNotice, keyMissingNotice,
162
164
  parseModelFallback,
@@ -517,6 +519,7 @@ const THINKING_WORDS = (() => {
517
519
  * file is missing is playful too, and the last-resort default is a real word rather than the
518
520
  * one word the list is designed to avoid.
519
521
  */
522
+ let lastHintIdx = -1; // the tip the previous turn showed (#234)
520
523
  const pickWord = () =>
521
524
  THINKING_WORDS[Math.floor(Math.random() * THINKING_WORDS.length)] || "Shaving the yak";
522
525
 
@@ -991,6 +994,7 @@ const COMMANDS = [
991
994
  // "/model" has meant the CODING model since task #44, and "agent model" vs "agent coding model"
992
995
  // has already confused the person who built both. The descriptions carry the distinction.
993
996
  { name: "/model", desc: "switch the CODING model (the one that writes code) for this session" },
997
+ { name: "/quick-models", desc: "pick up to 2 coding models to flip between with ← / → at the prompt" },
994
998
  // /provider IS NOT LISTED HERE ANY MORE — see HIDDEN_COMMANDS. It was promoted to a top-level
995
999
  // command when editing a provider was genuinely unreachable, and by the very next change
996
1000
  // /model carried "add or change a provider — Claude API, OpenAI, Kimi…" as a row in EVERY branch of
@@ -1855,6 +1859,7 @@ const fallbackNoticeRows = () => {
1855
1859
  ...(codingKeyMissingKnown === true
1856
1860
  ? [keyMissingNotice({ coder: activeCodingModelId(), width, gutter: GUTTER })]
1857
1861
  : []),
1862
+ ...[quickWarning(quickModels, quickActiveId(), { width, gutter: GUTTER })].filter(Boolean),
1858
1863
  ];
1859
1864
  };
1860
1865
 
@@ -2364,7 +2369,7 @@ function slashCloseSeq(line, cursor, cols) {
2364
2369
  let editMode = "accept"; // "accept" | "manual"
2365
2370
  const modeLineText = () =>
2366
2371
  white("✎ " + (editMode === "accept" ? "accept edits on" : "manually edit")) +
2367
- dim(" (shift+tab to switch)");
2372
+ dim(" (shift+tab to switch)") + (quickBarHint(quickModels) ? dim(" " + quickBarHint(quickModels)) : "");
2368
2373
  // Token usage for the bar's middle row (task #104): the LAST turn's code-model input +
2369
2374
  // output, then the coder's lifetime output total. Unlike the old per-turn print, this row is
2370
2375
  // ALWAYS on screen — a turn that did no code-model work honestly reads "0", and the lifetime
@@ -4732,6 +4737,14 @@ if (process.stdin.isTTY) {
4732
4737
  if (key && key.ctrl && key.name === "t" && !slashOverlayActive) {
4733
4738
  if (toggleNewestThought()) return;
4734
4739
  }
4740
+ // ← / → at the EMPTY prompt flips between the two quick models (#233). Only at an idle prompt:
4741
+ // not in a question (askWaiting), not during a command or a turn (inputVisible is false then),
4742
+ // not over the slash menu, and never on a line with text — there the arrows move the caret.
4743
+ if (key && (key.name === "left" || key.name === "right") && !key.ctrl && !key.meta && !key.shift
4744
+ && rl.line === "" && inputVisible && !askWaiting && !slashOverlayActive && quickReady(quickModels)) {
4745
+ void quickSwitch();
4746
+ return;
4747
+ }
4735
4748
  // Shift+Tab cycles the edit mode shown in the bottom bar (display-only). Skip while the
4736
4749
  // slash overlay owns the screen so we don't fight its redraw.
4737
4750
  if (key && key.name === "tab" && key.shift) {
@@ -5779,6 +5792,25 @@ async function loadSessionCoding(cwd) {
5779
5792
  }
5780
5793
  }
5781
5794
 
5795
+ async function loadQuickModels(cwd) {
5796
+ try {
5797
+ return normalizeQuick(JSON.parse(await readFile(sessionFilePath(cwd), "utf8"))?.quickModels);
5798
+ } catch {
5799
+ return [];
5800
+ }
5801
+ }
5802
+
5803
+ async function saveQuickModels(cwd) {
5804
+ try {
5805
+ await updateSessionFile(cwd, (raw) => {
5806
+ raw.quickModels = quickModels;
5807
+ raw.updatedAt = new Date().toISOString();
5808
+ });
5809
+ } catch (err) {
5810
+ console.error(dim(`(quick models save failed: ${err.message})`));
5811
+ }
5812
+ }
5813
+
5782
5814
  /** This folder's stored choice — the SHAPE reading lives in terminal-layout, where it is
5783
5815
  * testable; only the file I/O is here. */
5784
5816
  async function loadSessionRagStorage(cwd) {
@@ -6096,9 +6128,25 @@ async function runModelDoctor() {
6096
6128
  "\n" + GUTTER +
6097
6129
  (bad.length === 0
6098
6130
  ? accent("✓ every configured model answered.")
6099
- : red(`✗ ${bad.length} of ${results.length} did not answer.`)) +
6100
- "\n"
6131
+ : red(`✗ ${bad.length} of ${results.length} did not answer.`))
6132
+ );
6133
+ // THE GU SERVICE TOO (#413): every model can answer and a coding turn still fail, because its
6134
+ // instructions come from the gu service — reported on a Windows VM where only "hi" worked.
6135
+ const sc = await import("./service-check.mjs");
6136
+ const verdict = await withPendingStatus("the gu service", async () =>
6137
+ sc.checkGuService({
6138
+ hostedBase: (await import("./client-expert-api.mjs")).hostedApiBase(),
6139
+ apiBase,
6140
+ workerKey,
6141
+ localApi: isLocalApiBase(apiBase),
6142
+ proxy: await (await import("./proxy-store.mjs")).loadProxy().catch(() => null),
6143
+ dispatcherFor: (await import("./proxy-dispatcher.mjs")).dispatcherFor,
6144
+ })
6101
6145
  );
6146
+ for (const [i, l] of verdict.lines.entries()) {
6147
+ console.log(GUTTER + (i === 0 ? (verdict.ok ? accent(l) : red(l)) : dim(l)));
6148
+ }
6149
+ console.log("");
6102
6150
  }
6103
6151
 
6104
6152
  /** THROUGH THE PROXY (#160), like every other model call — a bare fetch is what fails on a
@@ -6108,410 +6156,6 @@ async function doctorFetch(url, init) {
6108
6156
  return proxyFetch(url, init);
6109
6157
  }
6110
6158
 
6111
- async function reconfigureOllamaServer() {
6112
- const os = await import("./ollama-setup.mjs");
6113
- const am = await import("./agent-model-command.mjs");
6114
- const md = await import("./model-doctor.mjs");
6115
- const { proxyFetch } = await import("./proxy-dispatcher.mjs");
6116
-
6117
- const settings = await withPendingStatus("reading your model settings", fetchWorkerSettings);
6118
- if (!settings) {
6119
- console.log(red(" couldn't reach the API to read your current settings.\n"));
6120
- return;
6121
- }
6122
-
6123
- /** Load a box's models THROUGH THE PROXY (#160) — a bare fetch is what fails on a restricted
6124
- * network, and it fails as "no models", which is the wrong story entirely. */
6125
- const load = async (n, key = "") => {
6126
- const res = await withPendingStatus(`loading models from ${n.host}`, () =>
6127
- proxyFetch(os.tagsUrlFor(n), {
6128
- signal: AbortSignal.timeout(8000),
6129
- // #301: the key typed for this server; absent → the request is what it always was.
6130
- ...(key ? { headers: { Authorization: `Bearer ${key}` } } : {}),
6131
- })
6132
- );
6133
- // The status is kept: a 404 here means "answered, but not an Ollama" (#43), not "unreachable".
6134
- if (!res.ok) throw Object.assign(new Error(`HTTP ${res.status}`), { httpStatus: res.status });
6135
- return am.parseOllamaTags(await res.json().catch(() => null));
6136
- };
6137
-
6138
- // #301: the way out, said under every server question (full once, short after), and the keys
6139
- // typed for servers, by host — saved against their rows once the settings are written.
6140
- const ui = await import("./model-setup-ui.mjs");
6141
- const hint = ui.createHinter();
6142
- const typedKeys = new Map();
6143
- const askKeyFor = async (host) => {
6144
- if (typedKeys.has(host)) return typedKeys.get(host); // same server for both roles → asked once
6145
- console.log(dim(` ${hint()}`));
6146
- // askSecret: masked, kept out of history; Esc throws PromptCancelled → /model is cancelled
6147
- // with nothing changed, which is what "skip all" means here.
6148
- const k = (await askSecret(` API key for ${host} (Enter for none): `)).trim();
6149
- typedKeys.set(host, k);
6150
- console.log(GUTTER + dim(ui.keyConfirmation(host, k)));
6151
- return k;
6152
- };
6153
-
6154
- /** Ask for one server and load it. Returns {normalized, models} or null (declined/failed). */
6155
- const askServer = async (label, dflt, { require = false, short = "server", role = "coder" } = {}) => {
6156
- for (let attempt = 0; attempt < 3; attempt++) {
6157
- // The address goes ABOVE, in full — it is long, it is the thing Enter accepts, and the
6158
- // frozen row is too narrow to hold it without truncating exactly that. The row itself
6159
- // carries a short label so it is obvious WHAT is being asked for.
6160
- // Shown WITHOUT /v1 (task #220 follow-up): the suffix in a default reads as an
6161
- // instruction, leaving the reader unsure whether their own address needs it. It does not
6162
- // — normalizeOllamaUrl adds it — and displayUrl round-trips, so what is shown can be fed
6163
- // straight back as the default.
6164
- console.log(dim(` ${label} — Enter keeps ${os.displayUrl(dflt)}`));
6165
- console.log(dim(` ${hint()}`));
6166
- // THE ROW MUST SAY WHICH SERVER IT IS ASKING FOR. It said a bare "server:" — chosen to
6167
- // fit the frozen row, which traded away the one thing the label was for. Two questions
6168
- // in a row, both reading "server:", and the reader cannot tell which one they are
6169
- // answering (reported: "it should say 'Agent + Embedder server' then 'Coding Server',
6170
- // it only says 'server:'"). `short` is the role in the fewest words that still name it.
6171
- const typed = (await ask(` ${short}: `)).trim();
6172
- // "s" is the advertised skip (#301); the older words still work.
6173
- if (ui.readStepAnswer(typed).kind === "skip") return null;
6174
- // EVERY READING OF A BARE HOSTNAME, in order. Typing "ollama2.getmarsic.cc" means
6175
- // https://…:443 behind a proxy, while "192.168.1.40" means http://…:11434 — one guess
6176
- // is right for one of them and produces a timeout that looks like a dead server for the
6177
- // other. See urlCandidates; the load decides, not the heuristic.
6178
- const candidates = os.urlCandidates(typed || dflt);
6179
- if (!candidates.length) {
6180
- console.log(dim(" that is not a server address — try 192.168.1.40 or a full URL"));
6181
- continue;
6182
- }
6183
- let n = null;
6184
- let models = null;
6185
- let lastErr = null;
6186
- // #301: a TYPED public address may need a key — asked before listing, so a server that
6187
- // needs one can list its models. A LAN box is never asked.
6188
- let key = "";
6189
- if (ui.shouldAskKey({ typed, host: candidates[0].host })) key = await askKeyFor(candidates[0].host);
6190
- const tryAll = async () => {
6191
- for (const cand of candidates) {
6192
- try {
6193
- models = await load(cand, key);
6194
- n = cand;
6195
- if (candidates.length > 1) console.log(dim(` using ${os.displayUrl(cand.url)}`));
6196
- return;
6197
- } catch (err) {
6198
- lastErr = err;
6199
- }
6200
- }
6201
- };
6202
- await tryAll();
6203
- // Refused for want of credentials: ask now, whatever the address looked like, and list again.
6204
- if (!n && ui.needsKeyAfterStatus(lastErr?.httpStatus) && !key) {
6205
- console.log(yellow(` ${candidates[0].host} needs an API key to list its models.`));
6206
- typedKeys.delete(candidates[0].host);
6207
- key = await askKeyFor(candidates[0].host);
6208
- if (key) await tryAll();
6209
- }
6210
- if (!n && ui.needsKeyAfterStatus(lastErr?.httpStatus)) {
6211
- console.log(red(` ${candidates[0].host} refused the request (HTTP ${lastErr.httpStatus})${key ? " — that key was not accepted" : ""}.`));
6212
- continue;
6213
- }
6214
- if (!n) {
6215
- if (lastErr?.httpStatus === 404) {
6216
- // IT ANSWERED — it is simply not an Ollama (#43). "Couldn't reach … is Ollama listening"
6217
- // sent people to debug a server that was working.
6218
- for (const l of am.describeNotOllama(candidates[0].host, role)) console.log(yellow(` ${l}`));
6219
- continue;
6220
- }
6221
- // One address typed, one failure reported — naming every candidate would be two errors
6222
- // for one mistake, and neither would be the address they gave.
6223
- console.log(red(` couldn't reach ${candidates[0].host}: ${os.networkReason(lastErr)}`));
6224
- console.log(dim(" check the address, that Ollama is listening on the network, and /proxy."));
6225
- continue;
6226
- }
6227
- const usable = os.usableModelList(models);
6228
- if (!usable.ok) {
6229
- for (const l of os.describeUnusableList(usable.why, n.host)) console.log(dim(` ${l}`));
6230
- continue;
6231
- }
6232
- if (require) {
6233
- // THE AGENT AND THE EMBEDDER ARE NOT A FREE CHOICE. A box without them is "not ready",
6234
- // not "wrong" — it can still be a perfectly good coder host, so this refuses the ROLE
6235
- // rather than the server.
6236
- const need = os.checkRequired(models);
6237
- if (!need.ok) {
6238
- for (const l of os.describeNotReady(n.host, need.missing)) console.log(yellow(` ${l}`));
6239
- console.log(dim(" (or type s to skip — you can set it later with /model)"));
6240
- continue;
6241
- }
6242
- console.log(dim(` ✓ ${n.host} has the ${os.REQUIRED_AGENT_PREFIX} family and the embedder`));
6243
- }
6244
- console.log(dim(` found ${models.length} model${models.length === 1 ? "" : "s"} on ${n.host}`));
6245
- return { normalized: n, models };
6246
- }
6247
- return null;
6248
- };
6249
-
6250
- // AN AGENT ON A PROVIDER IS NOT ASKED ABOUT HERE (#43). This flow is for Ollama boxes; offering
6251
- // https://api.openai.com as the "agent + embedding server" could only fail its /api/tags probe and
6252
- // ask again. Keep it, say where it is changed, and go on to the coder.
6253
- const agentOnProvider = am.agentIsOnProvider(settings);
6254
- const curAgent =
6255
- String(settings.agentModelUrl ?? "").trim() || "https://ollama1.getmarsic.cc/v1";
6256
- const curCode = am.coderServerDefault(settings);
6257
-
6258
- let agent = null;
6259
- if (agentOnProvider) {
6260
- const host = hostOf(String(settings.agentModelUrl ?? ""));
6261
- console.log(dim(` The agent model is on ${host} (OpenAI-compatible) — kept as it is; change it with /agent-model.`));
6262
- console.log(dim(" Enter keeps the current address."));
6263
- } else {
6264
- console.log(dim(" TWO servers may be used: the agent + embedder on one, the coder on another."));
6265
- console.log(dim(" Enter keeps the current address."));
6266
- agent = await askServer("Agent + embedding server", curAgent, {
6267
- require: true,
6268
- short: "agent + embed server",
6269
- role: "agent",
6270
- });
6271
- }
6272
- const coder = await askServer("Coding-model server", agent?.normalized?.url ?? curCode, {
6273
- short: "coding server",
6274
- role: "coder",
6275
- });
6276
-
6277
- // Task #14: skipping is a real answer, not a failure — but the bottom bar must keep saying
6278
- // what it cost. Declining the AGENT + embed server leaves both roles unset → both warnings.
6279
- if (!agent && !agentOnProvider) {
6280
- agentMissing = true; // no agent model chosen → the agent falls back to the coding model
6281
- embedMissing = true; // the embedder lives on the agent server → RAG is off until /embed-model
6282
- }
6283
-
6284
- if (!agent && !coder) {
6285
- console.log(dim(" nothing set — models stay as they are.\n"));
6286
- return;
6287
- }
6288
-
6289
- // THE AGENT IS CHOSEN TOO (user, 2026-09-06). checkRequired now gates on the gemma4 FAMILY
6290
- // rather than one exact tag, so "the box is ready" no longer names a single model — which of
6291
- // its gemma4s routes every turn is a real decision, and a box commonly holds a small one for
6292
- // speed beside a larger one for quality. Skipped when there is only one candidate: a picker
6293
- // with a single row is a question with one answer.
6294
- let chat = "";
6295
- if (agent) {
6296
- // Task #36: the saved agent stays a choice (and the default) even when it is not a gemma4.
6297
- const { cands, current: agentDefault } = os.agentChoices(
6298
- agent.models,
6299
- String(settings.agentModelName ?? "").trim()
6300
- );
6301
- if (cands.length <= 1) {
6302
- chat = agentDefault;
6303
- } else {
6304
- const labels = cands.map((m) => {
6305
- const size = am.formatSize(m.sizeBytes);
6306
- return `${m.model}${size ? dim(` ${size}`) : ""}${m.model === agentDefault ? dim(" (current)") : ""}`;
6307
- });
6308
- console.log(dim(` AGENT model — routes every turn, writes the plain replies (${PICK_HINT}):`));
6309
- const pick = await pickFromList(
6310
- labels,
6311
- Math.max(0, cands.findIndex((m) => m.model === agentDefault))
6312
- );
6313
- if (pick < 0) {
6314
- console.log(dim(" (cancelled — nothing changed)\n"));
6315
- return;
6316
- }
6317
- chat = cands[pick].model;
6318
- }
6319
- }
6320
-
6321
- // The CODER is chosen from whatever its box has; the EMBEDDER is not a choice at all.
6322
- let code = "";
6323
- let extras = [];
6324
- if (coder) {
6325
- const roles = os.defaultRoles(coder.models, {
6326
- preferred: { code: String(settings.agentCodingModelName ?? "").trim() },
6327
- });
6328
- const labels = coder.models.map((m) => {
6329
- const size = am.formatSize(m.sizeBytes);
6330
- const kind = am.isLikelyEmbedder(m) ? dim(" · embedder") : "";
6331
- return `${m.model}${size ? dim(` ${size}`) : ""}${kind}${m.model === roles.code ? dim(" (current)") : ""}`;
6332
- });
6333
- console.log(dim(` CODING model — writes the code; worth waiting for (${PICK_HINT}):`));
6334
- const idx = await pickFromList(labels, Math.max(0, coder.models.findIndex((m) => m.model === roles.code)));
6335
- if (idx < 0) {
6336
- console.log(dim(" (cancelled — nothing changed)\n"));
6337
- return;
6338
- }
6339
- code = coder.models[idx].model;
6340
-
6341
- const rest = coder.models.filter((m) => !am.isLikelyEmbedder(m) && m.model !== code);
6342
- if (rest.length) {
6343
- // Pre-ticked from what /model already offers, so re-running starts from what is saved
6344
- // rather than from empty — otherwise every re-run silently drops the extras you had.
6345
- const already = new Set(
6346
- (Array.isArray(settings.agentCodingBackends) ? settings.agentCodingBackends : [])
6347
- .filter((b) => (b?.kind ?? "") === "ollama")
6348
- .flatMap((b) => (Array.isArray(b.models) ? b.models : []))
6349
- );
6350
- console.log(
6351
- dim(` Also offer these in /model? (up to ${MAX_ALT_CODERS} · ${PICK_MANY_HINT}):`)
6352
- );
6353
- const ticks = await pickManyFromList(
6354
- rest.map((m) => `${m.model}${am.formatSize(m.sizeBytes) ? dim(` ${am.formatSize(m.sizeBytes)}`) : ""}`),
6355
- rest.map((m, i) => (already.has(m.model) ? i : -1)).filter((i) => i >= 0),
6356
- { max: Math.max(MAX_ALT_CODERS, rest.filter((m) => already.has(m.model)).length) }
6357
- );
6358
- // null is Escape and cancels the command; an EMPTY list is a legitimate "offer none".
6359
- if (ticks === null) {
6360
- console.log(dim(" (cancelled — nothing changed)\n"));
6361
- return;
6362
- }
6363
- extras = ticks.map((i) => rest[i].model);
6364
- }
6365
- }
6366
-
6367
- // WRITE ONLY THE HALVES THAT WERE SET. Someone who configured a coder but skipped the agent
6368
- // box must not have their existing agent model wiped — half-configured is a real state and
6369
- // clobbering the other half is the opposite of what skipping asked for.
6370
- const patch = {};
6371
- if (agent) {
6372
- Object.assign(patch, {
6373
- agentModelUrl: agent.normalized.url,
6374
- agentModelName: chat,
6375
- agentModel: chat,
6376
- ragEnabled: true,
6377
- ragEmbedUrl: agent.normalized.url,
6378
- ragEmbedModel: os.defaultEmbedModel?.(agent.models) ?? os.REQUIRED_EMBED_MODEL,
6379
- });
6380
- }
6381
- if (coder) {
6382
- Object.assign(patch, {
6383
- agentCodingModelUrl: coder.normalized.url,
6384
- agentCodingModelName: code,
6385
- agentCodingModelKind: "ollama",
6386
- agentCodingDefaultKind: "ollama",
6387
- // MERGED, not replaced (#43): the providers already configured — and the keys bound to them —
6388
- // survive choosing a new Ollama server.
6389
- agentCodingBackends: (await import("./client-expert-api.mjs")).mergeOllamaServerBackends(
6390
- (await import("./coding-provider-setup.mjs")).existingBackendRows(settings),
6391
- (await import("./client-expert-api.mjs")).codingBackendsForClientExpert({
6392
- url: coder.normalized.url,
6393
- chat: agent ? chat : code,
6394
- code,
6395
- altCoders: extras,
6396
- }),
6397
- { isCloud: (await import("./coding-key-prompt.mjs")).isOllamaCloudUrl }
6398
- ),
6399
- });
6400
- }
6401
-
6402
- const saved = await withPendingStatus("saving", () =>
6403
- md.saveWorkerSettings(apiBase, workerKey, patch)
6404
- );
6405
- if (!saved) {
6406
- console.log(red(" couldn't save that to this machine — nothing changed."));
6407
- console.log(GUTTER + dim("if this machine's local API is old, restart it with `gu api restart`.\n"));
6408
- return;
6409
- }
6410
- // #301: THE KEYS TYPED FOR THE SERVERS, bound to their rows now that the rows exist (the API
6411
- // refuses a key for a row it does not have). Before the cloud check below, so a key typed for
6412
- // the Ollama cloud is not asked for a second time. Reported by host, never by value.
6413
- {
6414
- const used = [agent?.normalized?.url, coder?.normalized?.url].filter(Boolean);
6415
- // By HOSTNAME: the key was asked for urlCandidates' host, which carries no port, while a URL's
6416
- // .host does — box.example.com:8443 would otherwise never match its own key.
6417
- const hostnameOf = (u) => { try { return new URL(u).hostname; } catch { return ""; } };
6418
- const pending = [...typedKeys].filter(([host, k]) => k && used.some((u) => hostnameOf(u) === host));
6419
- if (pending.length) {
6420
- const fresh = await withPendingStatus("storing the key", fetchWorkerSettings);
6421
- const ps = await import("./coding-provider-setup.mjs");
6422
- for (const [host, k] of pending) {
6423
- const url = used.find((u) => hostnameOf(u) === host);
6424
- const found = ps.providerRowForUrl(ps.existingBackendRows(fresh ?? {}), url);
6425
- if (!found) {
6426
- console.log(yellow(` no server row holds ${host} yet — add its key with ctrl+k in /model.`));
6427
- continue;
6428
- }
6429
- const ok = await withPendingStatus("saving the key", () => addCodingKey(host, k, found.id));
6430
- console.log(ok
6431
- ? GUTTER + dim(`✓ key stored for ${host} — the server's default key`)
6432
- : red(` couldn't store the key for ${host} — add it with ctrl+k.`));
6433
- }
6434
- }
6435
- }
6436
- // THE OLLAMA CLOUD NEEDS ITS OWN KEY. Reported: choosing https://ollama.com here listed its 20 models
6437
- // (that list is public) and saved, without a key — and the next turn failed "REJECTED the coding API
6438
- // key", having borrowed an unrelated sk-… key. Asked for now, bound to the row just saved.
6439
- {
6440
- const cloudUrls = [coder?.normalized?.url, agent?.normalized?.url].filter(
6441
- (u, i, all) => u && all.indexOf(u) === i && isOllamaCloudUrl(u)
6442
- );
6443
- if (cloudUrls.length) {
6444
- const fresh = await withPendingStatus("checking the Ollama cloud key", fetchWorkerSettings);
6445
- const keys = await fetchCodingKeys();
6446
- const ps = await import("./coding-provider-setup.mjs");
6447
- for (const u of cloudUrls) {
6448
- const found = ps.providerRowForUrl(ps.existingBackendRows(fresh ?? {}), u);
6449
- if (found && !am.boundKeyFor(keys, found.id)) {
6450
- console.log(yellow(` ${hostOf(u)} is the Ollama cloud — it needs its own API key (the model list worked without one).`));
6451
- // The shared key prompt (task #245); the server's first key becomes its default.
6452
- const { name, secret } = await askServerKey({ host: hostOf(u) });
6453
- if (secret && !(await withPendingStatus("saving the key", () => addCodingKey(name, secret, found.id)))) {
6454
- console.log(red(" couldn't save the key — add it with ctrl+k.\n"));
6455
- }
6456
- }
6457
- }
6458
- }
6459
- }
6460
- // THE ACCOUNT DEFAULT JUST CHANGED — say so in memory, or the bar reports the old one.
6461
- //
6462
- // The override is cleared just below, correctly: what was picked here IS the account default
6463
- // now, so an override would be a duplicate of it. But the bar reads
6464
- // `sessionCodingLabel || defaultCodingModel`, and defaultCodingModel was read from the
6465
- // startup payload and never touched again. Clearing one while the other stayed stale is why
6466
- // the confirmation said gemma4 and the footer kept showing the old coder (reported
6467
- // 2026-09-05). Both halves of that expression have to be current, not one.
6468
- if (coder) {
6469
- defaultCodingModel = code;
6470
- defaultCodingUrl = coder.normalized.url;
6471
- }
6472
- // The session override names a model on the OLD box; leaving it set would send a name the new
6473
- // host has never heard of, with the settings now correct — the most confusing shape of all.
6474
- sessionCodingModel = "";
6475
- sessionCodingLabel = "";
6476
- sessionCodingUrl = "";
6477
- // Persist the CLEARING too. The stored override names a model on the OLD box; leaving it on
6478
- // disk would resurrect it at the next launch, against a server that has never heard of it.
6479
- await saveSessionCoding(resolve(process.cwd()));
6480
- await syncActiveCoderWhere(); // the bar now names the account default — with ITS host (#263)
6481
- // The GLOBAL output-token total is keyed to the coding backend (task #125), and the backend
6482
- // just moved — re-fetch it for the new one, exactly as /model does after a pick, or the
6483
- // footer keeps counting against the box that is no longer in use.
6484
- if (coder) {
6485
- const t = await fetchOutputTokenTotals(resolve(process.cwd()));
6486
- const g = pickGlobalTotal(t);
6487
- if (g !== null) globalOutputTokens = g;
6488
- }
6489
- paintBar(); // …and show all of it now, not at the next keystroke
6490
- console.log(accent(" ✓ models saved"));
6491
- if (agent) {
6492
- console.log(dim(` chat ${chat} @ ${agent.normalized.host}`));
6493
- console.log(dim(` RAG ${os.defaultEmbedModel?.(agent.models) ?? os.REQUIRED_EMBED_MODEL} @ ${agent.normalized.host}`));
6494
- } else {
6495
- console.log(dim(" chat — not set (agent server skipped)"));
6496
- }
6497
- console.log(
6498
- coder
6499
- ? dim(` code ${code} @ ${coder.normalized.host}`)
6500
- : dim(" code — not set (coding server skipped)")
6501
- );
6502
- if (extras.length) console.log(dim(` also ${extras.join(", ")} — offered by /model`));
6503
- // Compare to the embedder this box was seeded with — an accepted OTHER embedder (a
6504
- // qwen3-embedding-only box) was written to settings above, so REQUIRED_EMBED_MODEL would
6505
- // falsely scream "changed" while the seeded value is exactly what settings now hold.
6506
- const seededEmbed = agent ? (os.defaultEmbedModel?.(agent.models) ?? os.REQUIRED_EMBED_MODEL) : "";
6507
- // Only a CHANGE when there was an embedder before — from none it is simply set (task #36).
6508
- const prevEmbed = String(settings.ragEmbedModel ?? "").trim();
6509
- if (agent && prevEmbed && prevEmbed !== seededEmbed) {
6510
- console.log(dim(" the embedder changed — existing RAG indexes need rebuilding (/rag)."));
6511
- }
6512
- console.log(dim(" takes effect on your next question.\n"));
6513
- }
6514
-
6515
6159
  // ---------- fetch the ready-to-run agent payload from user settings ----------
6516
6160
  // /model command: fetch the allowed coding models (default + web-curated whitelist),
6517
6161
  // show a numbered picker, and set sessionCodingModel to the choice for this session.
@@ -6775,8 +6419,7 @@ async function changeProviderModel(ps, rows, p) {
6775
6419
  return;
6776
6420
  }
6777
6421
  // THE FOLDER'S PICK NAMES THE OLD MODEL. Leaving it would send a name this provider no longer
6778
- // offers, with the settings now correct — the most confusing shape there is (the same trap
6779
- // reconfigureOllamaServer clears its override for).
6422
+ // offers, with the settings now correct — the most confusing shape there is.
6780
6423
  if (sessionCodingLabel === p.model || sessionCodingModel.endsWith(`::${p.model}`)) {
6781
6424
  sessionCodingModel = "";
6782
6425
  sessionCodingLabel = "";
@@ -7275,6 +6918,76 @@ async function useCoderInThisFolder({ ps, rows, beforeChoices, url, host, locali
7275
6918
  { const g = pickGlobalTotal(t); if (g !== null) globalOutputTokens = g; }
7276
6919
  }
7277
6920
 
6921
+
6922
+ /**
6923
+ * The picker rows for a list of coding choices, and the tags that tell same-named ones apart.
6924
+ * Shared by /model and /quick-models (#233) so the two lists read identically.
6925
+ */
6926
+ function codingChoiceLabels(choices, def) {
6927
+ const { tags, show: showTag } = codingChoiceTags(choices);
6928
+ const labels = choices.map((c, i) => {
6929
+ // Every row says WHERE it runs in front of the name ("ollama.com/kimi-k3",
6930
+ // "api.anthropic.com/claude-opus-5" — #263), so the kind tag is only for a row with no server.
6931
+ const where = coderWhere(c.kind, c.url);
6932
+ const tag = showTag && !where ? ` ${dim(`· ${tags[i]}`)}` : "";
6933
+ return `${coderDisplayName(c.model, where)}${tag}${c.id === def ? " (default)" : ""}`;
6934
+ });
6935
+ return { labels, tags, showTag };
6936
+ }
6937
+
6938
+ /**
6939
+ * /quick-models — tick up to two coding models to flip between with ← / → (#233). The list is the
6940
+ * same (backend, model) choices /model offers, pre-ticked from what is saved; Enter saves, Esc leaves
6941
+ * it as it was.
6942
+ */
6943
+ async function chooseQuickModels() {
6944
+ let probe;
6945
+ try {
6946
+ probe = await withPendingStatus("loading models", () => fetchAgentPayload([{ role: "user", content: "ping" }]));
6947
+ } catch (err) {
6948
+ console.log(red(` ${describeApiFailure(err, "couldn't load models")}\n`));
6949
+ return;
6950
+ }
6951
+ const def = (probe?.codingDefault ?? "").trim();
6952
+ const choices = Array.isArray(probe?.codingChoices) && probe.codingChoices.length
6953
+ ? probe.codingChoices
6954
+ : (Array.isArray(probe?.codingAllowed) ? probe.codingAllowed : []).map((id) => ({ id, model: id, kind: "" }));
6955
+ if (choices.length < 2) {
6956
+ console.log(dim(" Quick models need at least two coding models — add one with /model → add or change a provider.\n"));
6957
+ return;
6958
+ }
6959
+ const { labels } = codingChoiceLabels(choices, def);
6960
+ const pre = quickModels.map((q) => choices.findIndex((c) => c.id === q.id)).filter((i) => i >= 0);
6961
+ console.log(dim(` Tick up to ${QUICK_MAX} models to flip between with ← / → (space ticks · Enter saves · Esc cancels):`));
6962
+ const picked = await pickManyFromList(labels, pre, { max: QUICK_MAX });
6963
+ if (picked === null) {
6964
+ console.log(dim(" (cancelled)\n"));
6965
+ return;
6966
+ }
6967
+ quickModels = normalizeQuick(picked.map((i) => choices[i]));
6968
+ await saveQuickModels(resolve(process.cwd()));
6969
+ if (quickModels.length === 0) console.log(dim(" quick models cleared — ← / → does nothing.\n"));
6970
+ else if (quickModels.length === 1) console.log(accent(` ✓ 1 quick model: ${quickModels[0].model}`) + dim(" — pick a second to switch with ← / →\n"));
6971
+ else console.log(accent(` ✓ quick models: ${quickModels.map((c) => c.model).join(" ⇄ ")}`) + dim(" (← / → at the prompt)\n"));
6972
+ paintBar();
6973
+ }
6974
+
6975
+ /** ← / → at the empty prompt: flip to the other quick model, through the same steps as /model. */
6976
+ async function quickSwitch() {
6977
+ if (quickSwitching) return;
6978
+ const target = quickTarget(quickModels, quickActiveId());
6979
+ if (!target) return;
6980
+ quickSwitching = true;
6981
+ try {
6982
+ await applyChosenCoder(target, { quick: true });
6983
+ } catch (e) {
6984
+ if (!wasPromptCancelled(e)) console.log(red(` quick switch failed: ${e?.message ?? e}\n`));
6985
+ } finally {
6986
+ quickSwitching = false;
6987
+ paintBar();
6988
+ }
6989
+ }
6990
+
7278
6991
  async function chooseModel() {
7279
6992
  let probe;
7280
6993
  try {
@@ -7307,35 +7020,24 @@ async function chooseModel() {
7307
7020
  // through to add when there is genuinely nothing there.
7308
7021
  console.log(dim(" No coding model configured."));
7309
7022
  const go = await pickFromList(
7310
- [dim("leave it"), "add or change a provider — Claude API, OpenAI, Kimi…", "change the Ollama server this machine uses…"],
7023
+ [dim("leave it"), "add or change a provider — Claude API, OpenAI, Kimi…"],
7311
7024
  1
7312
7025
  );
7313
7026
  if (go === 1) await manageCodingProviders();
7314
- else if (go === 2) await reconfigureOllamaServer();
7315
7027
  else console.log("");
7316
7028
  return;
7317
7029
  }
7318
- // ONE ENTRY THAT IS NOT A MODEL (task #220 req 6): re-point this machine at a different Ollama
7319
- // box. Offered HERE because /model is where someone already is when they discover the coder is
7320
- // wrong — and on a Client Expert machine there is no web app to go and change it in. Appended
7321
- // last so it never shifts the index of a model anyone has muscle memory for.
7322
- // …and shown even when there is only ONE model, because "only one is available" is often the
7323
- // symptom of being pointed at the wrong box, and the old message sent people to a web app a
7324
- // Client Expert machine does not have.
7030
+ // Shown even with ONE model: "only one is available" often means the wrong server, and the
7031
+ // provider manager is where another one is added (an Ollama box is "another URL…").
7032
+ // #412 removed "change the Ollama server this machine uses…" from all three menus: it re-asked
7033
+ // the agent, embedder and coder servers in one go, which confused people picking a model.
7325
7034
  if (choices.length === 1) {
7326
7035
  console.log(dim(` Only one coding model is available: ${choices[0].model}.`));
7327
- // APPENDED AFTER the existing entry, here and below, so that entry keeps the index anyone
7328
- // who has used it already reaches for.
7329
7036
  const go = await pickFromList(
7330
- [
7331
- dim("keep it"),
7332
- "change the Ollama server this machine uses…",
7333
- "add or change a provider — Claude API, OpenAI, Kimi…",
7334
- ],
7037
+ [dim("keep it"), "add or change a provider — Claude API, OpenAI, Kimi…"],
7335
7038
  0
7336
7039
  );
7337
- if (go === 1) await reconfigureOllamaServer();
7338
- else if (go === 2) await manageCodingProviders();
7040
+ if (go === 1) await manageCodingProviders();
7339
7041
  else console.log("");
7340
7042
  return;
7341
7043
  }
@@ -7345,18 +7047,10 @@ async function chooseModel() {
7345
7047
  // Which tag tells these rows apart — the kind, or the provider the API derived from each
7346
7048
  // URL. The rule and its reasons live in coding-choice-tags.mjs (task #223): two
7347
7049
  // OpenAI-compatible providers are both "OpenAI-compatible", so the kind names neither.
7348
- const { tags, show: showTag } = codingChoiceTags(choices);
7349
- const labels = choices.map((c, i) => {
7350
- // Every row says WHERE it runs in front of the name ("ollama.com/kimi-k3",
7351
- // "api.anthropic.com/claude-opus-5" — #263), so the kind tag is only for a row with no server.
7352
- const where = coderWhere(c.kind, c.url);
7353
- const tag = showTag && !where ? ` ${dim(`· ${tags[i]}`)}` : "";
7354
- return `${coderDisplayName(c.model, where)}${tag}${c.id === def ? " (default)" : ""}`;
7355
- });
7050
+ const { labels, tags, showTag } = codingChoiceLabels(choices, def);
7356
7051
  const activeIdx = choices.findIndex((c) => c.id === active);
7357
- labels.push("change the Ollama server this machine uses…");
7358
- // Task #223. Appended LAST, after the #220 entry, for the same reason that one is appended
7359
- // after the models: a new row must never shift an index someone already has muscle memory for.
7052
+ // Task #223. Appended LAST, after the models: a new row must never shift an index someone
7053
+ // already has muscle memory for.
7360
7054
  //
7361
7055
  // "add or change" rather than "add", because add-only was a one-way door: a model id typed
7362
7056
  // wrong could not be corrected from anywhere, here or in web Settings. The entry leads to the
@@ -7377,14 +7071,21 @@ async function chooseModel() {
7377
7071
  console.log(dim(" (cancelled)\n"));
7378
7072
  return;
7379
7073
  }
7380
- // The appended entries sit past the last model, so they are the only indices without a choice.
7381
- // Their ORDER here must match the order they were pushed in, or a pick runs the other flow.
7074
+ // The one appended entry sits past the last model.
7382
7075
  if (idx >= choices.length) {
7383
- if (idx === choices.length) await reconfigureOllamaServer();
7384
- else await manageCodingProviders();
7076
+ await manageCodingProviders();
7385
7077
  return;
7386
7078
  }
7387
- const chosen = choices[idx];
7079
+ await applyChosenCoder(choices[idx], { def, tag: showTag ? tags[idx] : "" });
7080
+ }
7081
+
7082
+ /**
7083
+ * Make `chosen` ({ id, model, kind, url }) the coding model: session state, saved choice, the key
7084
+ * check and the lifetime token total. The tail of /model, extracted so /quick-models' ← / → (#233)
7085
+ * runs EXACTLY the same steps — a quick switch that skipped the key prompt would leave gu on a
7086
+ * coder it cannot use (#181). `quick` only quietens the output: one ✓ line, no key essay.
7087
+ */
7088
+ async function applyChosenCoder(chosen, { def = "", tag = "", quick = false } = {}) {
7388
7089
  // Empty override means "use the account default" — so don't send an override when the
7389
7090
  // user picks the default (keeps the payload clean and lets a later default change win).
7390
7091
  // GitHub #130: what to go back to if the key prompt for the new coder is cancelled (Esc).
@@ -7409,7 +7110,7 @@ async function chooseModel() {
7409
7110
  // WRITTEN NOW, not at the end of the next turn: `/model` then `/exit` is how the choice is
7410
7111
  // made and then tested, and that path never reaches saveSession.
7411
7112
  await saveSessionCoding(resolve(process.cwd()));
7412
- const where = showTag && !activeCoderWhere ? ` (${tags[idx]})` : "";
7113
+ const where = tag && !activeCoderWhere ? ` (${tag})` : "";
7413
7114
  console.log(
7414
7115
  accent(
7415
7116
  ` ✓ coding model → ${coderDisplayName(chosen.model, activeCoderWhere)}${where}${chosen.id === def ? " (default)" : ""}\n`
@@ -7480,7 +7181,7 @@ async function chooseModel() {
7480
7181
  backendId: ks.backendId,
7481
7182
  host: ks.provider,
7482
7183
  });
7483
- console.log(
7184
+ if (!quick) console.log(
7484
7185
  dim(" API key ") + accent(line.text) +
7485
7186
  (line.note ? orange(` · ${line.note}`) : "") +
7486
7187
  dim(` · ctrl+k to ${line.verb} it`)
@@ -7490,7 +7191,7 @@ async function chooseModel() {
7490
7191
  // the more common one — "how do I change the model" — with no visible answer at all. Reported
7491
7192
  // three times from exactly this view, twice after being told where the picker entry was.
7492
7193
  // /provider is hidden (see HIDDEN_COMMANDS): point at the row of /model that does the same.
7493
- console.log(dim(" to change this provider's model or URL, or remove it: /model → add or change a provider\n"));
7194
+ if (!quick) console.log(dim(" to change this provider's model or URL, or remove it: /model → add or change a provider\n"));
7494
7195
  }
7495
7196
  // Task #125: the GLOBAL lifetime total is per-backend, so re-fetch it for the newly
7496
7197
  // selected backend NOW — otherwise the footer would keep showing the previous backend's
@@ -11524,6 +11225,16 @@ let jobIdForApprovalRace = "";
11524
11225
  // Task #123: with several backends configured this is a QUALIFIED id ("<kind>::<model>"),
11525
11226
  // which resolves to a whole backend — URL, kind and API key — not just a model name.
11526
11227
  let sessionCodingModel = "";
11228
+ // QUICK MODELS (#233): up to two coding choices ({ id, model, kind, url }) that ← / → flips between at
11229
+ // the empty prompt. Per folder, in the session file, like the /model pick.
11230
+ let quickModels = [];
11231
+ let quickSwitching = false;
11232
+ /** The id the quick list compares against: the folder's pick, else the account default's choice id. */
11233
+ const quickActiveId = () => {
11234
+ if (sessionCodingModel) return sessionCodingModel;
11235
+ const d = quickModels.find((c) => c.id === defaultCodingModel) ?? quickModels.find((c) => c.model === defaultCodingModel);
11236
+ return d ? d.id : defaultCodingModel;
11237
+ };
11527
11238
  /**
11528
11239
  * WHICH STORED API KEY THIS FOLDER USES for the coding model (task #222, phase 3).
11529
11240
  *
@@ -12277,6 +11988,10 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
12277
11988
  let thinking = false;
12278
11989
  let thinkingSince = 0;
12279
11990
  let thinkWord = pickWord(); // playful word shown UNDER the in-progress line (flavor)
11991
+ // ONE TIP PER TURN (#234), under the word: stable for the whole turn (a tip that changed every few
11992
+ // seconds would be read as flicker), never the one the previous turn showed.
11993
+ const hintIdx = (lastHintIdx = pickHint(Math.random, lastHintIdx));
11994
+ let blockHintRows = 0; // 1 while the tip row is part of the live block (see the click arithmetic)
12280
11995
  let lastWordBucket = -1; // 12s window index → rotate the word once per window (task: 30-spinner)
12281
11996
  let almostDone = false; // set from the worker's "…almost completed thinking…" heartbeat
12282
11997
  let runningCmd = null; // the command currently executing (from a "Running → …" event)
@@ -12545,7 +12260,13 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
12545
12260
  // redrawn continuously, so a blank written into the transcript would either accumulate one
12546
12261
  // per redraw or be stranded above a block that has since moved. Owned by the block, they
12547
12262
  // are erased with it — always exactly one, wherever it currently is. See LEAD_ROWS/WORD_GAP.
12548
- const drawn = 2 + LEAD_ROWS + WORD_GAP + expLines.length + detailRows.length;
12263
+ // THE TIP (#234): after a few seconds of waiting, one dim row under the word. Not on a very
12264
+ // short terminal, not with the animation off, and never wrapped.
12265
+ const hintText = !stillStatus && hintIdx >= 0 && secs >= HINT_AFTER_SECONDS && hintFits(process.stdout.rows)
12266
+ ? hintRow(HINTS[hintIdx], { width: Math.max(20, process.stdout.columns || 80), indent: visibleLen(GUTTER) })
12267
+ : "";
12268
+ blockHintRows = hintText ? 1 : 0;
12269
+ const drawn = 2 + LEAD_ROWS + WORD_GAP + expLines.length + detailRows.length + blockHintRows;
12549
12270
  // The bottom bar rides along UNDER the live status while a turn runs, so it stays on screen
12550
12271
  // the whole time the agent is thinking instead of only at the prompt. It's part of the same
12551
12272
  // in-place block: the ticker redraws it every 120ms and clearStatus wipes it with the rest,
@@ -12616,6 +12337,7 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
12616
12337
  contentRows.push(
12617
12338
  clipRow(`${markLine(waitPaint(wc, glyph))}${waitPaint(wc, `${thinkWord}…`)}${outLabel}`)
12618
12339
  );
12340
+ if (hintText) contentRows.push(clipRow(GUTTER + dim(hintText)));
12619
12341
  // PINNED → the block IS frozen rows; unpinned (a window too short to reserve any) there is
12620
12342
  // no zone, so it keeps the old in-place draw at the output position.
12621
12343
  if (barPinned()) {
@@ -12660,7 +12382,7 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
12660
12382
  // LEAD_ROWS · thought · detail… · expansion… · WORD_GAP · word
12661
12383
  // so the responding rows run from `top` to the last expansion row — statusContentLines less
12662
12384
  // the fixed rows around them (the two gaps, the thought line and the word row).
12663
- if (row < top || row > top + statusContentLines - LEAD_ROWS - WORD_GAP - 2) return;
12385
+ if (row < top || row > top + statusContentLines - LEAD_ROWS - WORD_GAP - 2 - blockHintRows) return;
12664
12386
  // Only meaningful when there IS a fuller thought to reveal; otherwise ignore the click
12665
12387
  // so we don't flicker an empty expand.
12666
12388
  if (!thoughtExpanded && !liveExpandable) return;
@@ -13823,6 +13545,7 @@ async function main() {
13823
13545
  sessionEmbedKeyId = await loadSessionFolderKeyId(resolve(process.cwd()), "embed");
13824
13546
  }
13825
13547
  }
13548
+ quickModels = await loadQuickModels(resolve(process.cwd())); // #233 — folder file only, whatever the database said
13826
13549
  // Fetch the API key tail after loading the session, if a key is configured
13827
13550
  if (sessionCodingKeyId) {
13828
13551
  try {
@@ -13866,7 +13589,7 @@ async function main() {
13866
13589
  void syncEmbedWarning({ repair: true });
13867
13590
  }
13868
13591
  // A RESTORED PICK MUST STILL EXIST. The stored id names a model on the box this folder was
13869
- // last pointed at — and the box can change (`/model` → change the Ollama server), a model
13592
+ // last pointed at — and the server can change (`/model` → add or change a provider), a model
13870
13593
  // can be removed, or the folder can be opened on another machine entirely. Sending a name
13871
13594
  // the account no longer allows fails the FIRST TURN with "not in your allowed coding
13872
13595
  // models", which reads as a broken agent rather than as a stale preference.
@@ -14277,7 +14000,8 @@ async function main() {
14277
14000
  red("gu: this machine has no models set up yet.") +
14278
14001
  dim(
14279
14002
  "\n Nothing can run until it knows which Ollama server to use." +
14280
- "\n Start `gu`, then `/model` → change the Ollama server this machine uses." +
14003
+ "\n Start `gu`, then `/agent-model` → use an Ollama server, `/embed-model` for RAG," +
14004
+ "\n and `/model` → add a provider → another URL… for the coding model." +
14281
14005
  `\n The agent server needs a ${REQUIRED_AGENT_PREFIX} model (e.g. ${REQUIRED_AGENT_MODEL})` +
14282
14006
  `\n and any ONE of these embedders: ${ACCEPTED_EMBED_MODELS.map((m) => m.replace(/:latest$/, "")).join(", ")};` +
14283
14007
  "\n the agent and coding models are then yours to pick."
@@ -14765,6 +14489,10 @@ async function main() {
14765
14489
  paintBar();
14766
14490
  continue;
14767
14491
  }
14492
+ if (line === "/quick-models") {
14493
+ await chooseQuickModels();
14494
+ continue;
14495
+ }
14768
14496
  if (line === "/model") {
14769
14497
  await chooseModel();
14770
14498
  await refreshNoModelAtAll(); // #188: a coding model may have just been set → drop its row