@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/agent-model-command.mjs +0 -67
- package/client-expert-api.mjs +0 -36
- package/doctor-check.mjs +9 -1
- package/gonext_agent_chat.py +22 -4
- package/gu-cli.mjs +28 -0
- package/gu-repl.mjs +178 -450
- package/hints.mjs +67 -0
- package/model-doctor.mjs +2 -7
- package/package.json +4 -1
- package/platform-tools.mjs +30 -2
- package/quick-models.mjs +64 -0
- package/service-check.mjs +181 -0
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
|
-
|
|
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
|
|
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…"
|
|
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
|
-
//
|
|
7319
|
-
//
|
|
7320
|
-
//
|
|
7321
|
-
//
|
|
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
|
|
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,
|
|
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
|
-
|
|
7358
|
-
//
|
|
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
|
|
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
|
-
|
|
7384
|
-
else await manageCodingProviders();
|
|
7076
|
+
await manageCodingProviders();
|
|
7385
7077
|
return;
|
|
7386
7078
|
}
|
|
7387
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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` →
|
|
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
|