@tiens.nguyen/gu-cli 1.0.795 → 1.0.796
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client-expert-api.mjs +1 -1
- package/coding-choice-tags.mjs +1 -1
- package/coding-key-prompt.mjs +4 -4
- package/device-login.mjs +1 -1
- package/embed-model-command.mjs +37 -0
- package/gonext_agent_chat.py +5 -5
- package/gu-cli.mjs +2 -2
- package/gu-repl.mjs +99 -23
- package/model-prices.mjs +27 -0
- package/model-setup-ui.mjs +1 -1
- package/ollama-setup.mjs +5 -5
- package/package.json +1 -1
- package/proxy-config.mjs +3 -3
- package/terminal-hover.mjs +32 -1
- package/terminal-layout.mjs +55 -0
package/client-expert-api.mjs
CHANGED
|
@@ -701,7 +701,7 @@ export async function probeLocalOllama() {
|
|
|
701
701
|
*/
|
|
702
702
|
/** The shared Ollama every client machine can already reach. */
|
|
703
703
|
export const SHARED_OLLAMA_URL =
|
|
704
|
-
process.env.GONEXT_CLIENT_AGENT_URL?.trim() || "https://ollama1.
|
|
704
|
+
process.env.GONEXT_CLIENT_AGENT_URL?.trim() || "https://ollama1.getmarsic.cc/v1";
|
|
705
705
|
/**
|
|
706
706
|
* Chat model — the one the agent routes and thinks with, on every step of every turn.
|
|
707
707
|
*
|
package/coding-choice-tags.mjs
CHANGED
|
@@ -69,7 +69,7 @@ export function codingChoiceTags(choices) {
|
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
/**
|
|
72
|
-
* WHERE AN OLLAMA CODER RUNS — "ollama.com", "127.0.0.1", "ollama1.
|
|
72
|
+
* WHERE AN OLLAMA CODER RUNS — "ollama.com", "127.0.0.1", "ollama1.getmarsic.cc" — or "" for any
|
|
73
73
|
* other kind.
|
|
74
74
|
*
|
|
75
75
|
* Reported: "/model ✓ coding model → kimi-k3 (ollama)". The tag was the API's provider label, the
|
package/coding-key-prompt.mjs
CHANGED
|
@@ -37,10 +37,10 @@ export const SECRET_KEY_HINT = "type or paste the key, then press Enter — it s
|
|
|
37
37
|
* that does not exist and, worse, reappears on every start because it can never be satisfied.
|
|
38
38
|
*
|
|
39
39
|
* Reported live: a Client Expert machine whose coder is gemma4-quadro-49k on
|
|
40
|
-
* ollama1.
|
|
40
|
+
* ollama1.getmarsic.cc being asked, at every launch, for that model's "API key".
|
|
41
41
|
*
|
|
42
|
-
* Matched on the HOST, not a substring: `evil.com/?x=
|
|
43
|
-
* `
|
|
42
|
+
* Matched on the HOST, not a substring: `evil.com/?x=getmarsic.cc` and
|
|
43
|
+
* `getmarsic.cc.attacker.net` must not be treated as ours — being wrong here would suppress a
|
|
44
44
|
* genuine prompt and leave someone's coder quietly unusable.
|
|
45
45
|
*/
|
|
46
46
|
export function isKeylessCodingHost(url) {
|
|
@@ -51,7 +51,7 @@ export function isKeylessCodingHost(url) {
|
|
|
51
51
|
return false;
|
|
52
52
|
}
|
|
53
53
|
if (host === "127.0.0.1" || host === "localhost" || host === "::1" || host === "[::1]") return true;
|
|
54
|
-
return host === "
|
|
54
|
+
return host === "getmarsic.cc" || host.endsWith(".getmarsic.cc");
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
/**
|
package/device-login.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { homedir, platform } from "node:os";
|
|
|
8
8
|
import { join } from "node:path";
|
|
9
9
|
import dotenv from "dotenv";
|
|
10
10
|
|
|
11
|
-
export const DEFAULT_API_BASE = "https://chat-api-v2.
|
|
11
|
+
export const DEFAULT_API_BASE = "https://chat-api-v2.getmarsic.cc";
|
|
12
12
|
const ENV_FILE = join(homedir(), ".gonext", "worker.env");
|
|
13
13
|
const POLL_INTERVAL_MS = 2500;
|
|
14
14
|
const POLL_TIMEOUT_MS = 10 * 60_000; // matches the server's pairing TTL
|
package/embed-model-command.mjs
CHANGED
|
@@ -186,3 +186,40 @@ export function describeEmbedChange(plan) {
|
|
|
186
186
|
lines.push("They will be ignored at search time and rebuilt on the next search.");
|
|
187
187
|
return lines;
|
|
188
188
|
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* IS RAG ACTUALLY ON, AND IF NOT, WHY? (task #347)
|
|
192
|
+
*
|
|
193
|
+
* Reported: "Choose embed model but exit and gu again, it keeps asking 'embed model not set',
|
|
194
|
+
* again and again." Two settings decide it and they were treated as one: WHICH embedder
|
|
195
|
+
* (`ragEmbedModel` + where it is served) and WHETHER RAG is on (`ragEnabled`). /embed-model saved
|
|
196
|
+
* the first and never the second, and the bar's warning is driven by the second — so the choice
|
|
197
|
+
* was stored, never took effect, and the warning came back at every start.
|
|
198
|
+
*
|
|
199
|
+
* → { state, model }
|
|
200
|
+
* "on" an embedder is chosen and RAG is on
|
|
201
|
+
* "off" an embedder IS chosen, RAG is switched off — not "no embed model"
|
|
202
|
+
* "unset" no embedder was ever chosen
|
|
203
|
+
* Pure. `settings` is the worker-settings view (null/undefined = unknown → "unset").
|
|
204
|
+
*/
|
|
205
|
+
export function embedState(settings) {
|
|
206
|
+
const model = String(settings?.ragEmbedModel ?? "").trim();
|
|
207
|
+
const where = String(settings?.ragEmbedUrl ?? "").trim() || String(settings?.ragEmbedModelPath ?? "").trim();
|
|
208
|
+
if (!model || !where) return { state: "unset", model };
|
|
209
|
+
return { state: settings?.ragEnabled === true ? "on" : "off", model };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* What saving an embedder must write: the model, its server — AND RAG on. Choosing an embedder
|
|
214
|
+
* is the act of turning RAG on; a choice that leaves it off is the bug above.
|
|
215
|
+
*/
|
|
216
|
+
export function embedSaveFields({ url, model }) {
|
|
217
|
+
return { ragEmbedUrl: url, ragEmbedModel: model, ragEmbedKeyId: "", ragEnabled: true };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** The bar's row for a state that is not "on" — "" when RAG is on. One short row (#317). */
|
|
221
|
+
export function embedNotice({ state, model } = {}) {
|
|
222
|
+
if (state === "on") return "";
|
|
223
|
+
if (state === "off" && model) return `⚠ RAG is switched off — ${model} is set · /embed-model turns it on`;
|
|
224
|
+
return "⚠ no embed model — RAG is off · /embed-model";
|
|
225
|
+
}
|
package/gonext_agent_chat.py
CHANGED
|
@@ -1426,7 +1426,7 @@ def _resolve_coding_backend(kind, base_url, probe=None):
|
|
|
1426
1426
|
budget). Every branch now keys off this one value.
|
|
1427
1427
|
|
|
1428
1428
|
Order matters. The Ollama probe runs BEFORE the remote-host rule so a remote Ollama
|
|
1429
|
-
(ollama1.
|
|
1429
|
+
(ollama1.getmarsic.cc) still resolves to "ollama" and keeps its own treatment.
|
|
1430
1430
|
|
|
1431
1431
|
explicit kind → that value
|
|
1432
1432
|
probe says Ollama → "ollama"
|
|
@@ -1467,7 +1467,7 @@ def _sentinel_against_ollama(agent_base_url, agent_model_id, probe=None):
|
|
|
1467
1467
|
|
|
1468
1468
|
— an error naming a model the user never chose, on a machine whose settings look fine. That
|
|
1469
1469
|
is the whole defect: not that it fails, but that it fails illegibly and the reader has no
|
|
1470
|
-
thread to pull. Verified live against ollama1.
|
|
1470
|
+
thread to pull. Verified live against ollama1.getmarsic.cc, 2026-08-28.
|
|
1471
1471
|
|
|
1472
1472
|
WHY IT CANNOT BE FIXED WHERE IT IS PRODUCED. The API builds that payload with no idea which
|
|
1473
1473
|
KIND of server the agent URL points at — there is no agentModelKind, only
|
|
@@ -1505,7 +1505,7 @@ def _resolve_coding_target(agent_base_url, agent_model_id, raw_coding_base, raw_
|
|
|
1505
1505
|
Extracted from run_agent_chat unchanged (task #213). It decides the single most consequential
|
|
1506
1506
|
thing about a turn — the model that writes every code block — and it lived in the middle of a
|
|
1507
1507
|
400-line function, so no test could execute it. The Client Expert default puts the chat model,
|
|
1508
|
-
the coder and the embedder on ONE Ollama box (ollama1.
|
|
1508
|
+
the coder and the embedder on ONE Ollama box (ollama1.getmarsic.cc), which is exactly the shape
|
|
1509
1509
|
where "same server" and "different model" collide, and where a silent fall-back to the chat
|
|
1510
1510
|
model is invisible: both answers are a real model on a real server and the turn still works,
|
|
1511
1511
|
just not with the coder that was chosen.
|
|
@@ -3877,7 +3877,7 @@ def _is_request_timeout(err) -> bool:
|
|
|
3877
3877
|
likely STILL RUNNING on the box. Re-sending the identical request — streamed or
|
|
3878
3878
|
not — starts that same multi-minute generation again from zero.
|
|
3879
3879
|
|
|
3880
|
-
Live (2026-08-30, qwen3.8:27b on ollama1.
|
|
3880
|
+
Live (2026-08-30, qwen3.8:27b on ollama1.getmarsic.cc): a 7,251-token prompt produced no
|
|
3881
3881
|
first token inside the 600s client timeout. We logged "streamed generate failed (Request
|
|
3882
3882
|
timed out.) → falling back to non-streaming", told the user "Streaming unavailable", and
|
|
3883
3883
|
re-sent it unstreamed — buying a second 600s wait with no first-token signal at all.
|
|
@@ -10088,7 +10088,7 @@ def run_agent_chat(cfg):
|
|
|
10088
10088
|
# stream_agent: request the completion with stream=True and reassemble the
|
|
10089
10089
|
# deltas into one message. Enabled for Ollama, where a long single-shot
|
|
10090
10090
|
# (non-stream) generation sends nothing until the very end — a reverse proxy
|
|
10091
|
-
# in front of the box (ollama1.
|
|
10091
|
+
# in front of the box (ollama1.getmarsic.cc) then hits its idle read-timeout
|
|
10092
10092
|
# and kills the request mid-generation. Streaming keeps tokens flowing so the
|
|
10093
10093
|
# idle timer never fires. smolagents still receives the COMPLETE text, so its
|
|
10094
10094
|
# <code>/final_answer parsing is unaffected.
|
package/gu-cli.mjs
CHANGED
|
@@ -1926,7 +1926,7 @@ const AGENT_WARMUP_IDLE_MS = 30 * 60_000;
|
|
|
1926
1926
|
const AGENT_WARMUP_INTERVAL_MS = 240_000; // 4 min — under Ollama's 5-min default unload
|
|
1927
1927
|
// OCR translation/correction is hardcoded to a managed Ollama service + model.
|
|
1928
1928
|
// (The user-configurable "OCR translation model" chooser was removed.)
|
|
1929
|
-
const OCR_TRANSLATE_OLLAMA_URL = "https://ollama1.
|
|
1929
|
+
const OCR_TRANSLATE_OLLAMA_URL = "https://ollama1.getmarsic.cc";
|
|
1930
1930
|
const OCR_TRANSLATE_OLLAMA_MODEL = "qwen3:14b";
|
|
1931
1931
|
// Ollama defaults num_ctx to 2048 and silently truncates longer inputs (keeping
|
|
1932
1932
|
// only the tail), which would drop the start of a long OCR page. Pin the context
|
|
@@ -3068,7 +3068,7 @@ function normalizeCorrection(raw, originalText) {
|
|
|
3068
3068
|
|
|
3069
3069
|
/**
|
|
3070
3070
|
* Call an Ollama host's native chat endpoint (POST {base}/api/chat) for text
|
|
3071
|
-
* correction/translation, e.g. base "https://ollama1.
|
|
3071
|
+
* correction/translation, e.g. base "https://ollama1.getmarsic.cc" model "qwen3:14b".
|
|
3072
3072
|
* `think:false` suppresses reasoning models' <think> preamble so we get clean text.
|
|
3073
3073
|
*/
|
|
3074
3074
|
const OCR_CORRECT_SYSTEM_PROMPT =
|
package/gu-repl.mjs
CHANGED
|
@@ -138,7 +138,8 @@ import { unapprovedProjectServers } from "./mcp-command.mjs";
|
|
|
138
138
|
import { readHooks } from "./hooks-config.mjs";
|
|
139
139
|
import { readSkillSource, sourceLabel, SKILL_SOURCE_KEY } from "./skill-source.mjs";
|
|
140
140
|
import { codingBannerLabel, isOllamaCloudUrl, SECRET_KEY_HINT } from "./coding-key-prompt.mjs";
|
|
141
|
-
import { thoughtPrintSeq } from "./terminal-layout.mjs";
|
|
141
|
+
import { questionEchoText, thoughtPrintSeq } from "./terminal-layout.mjs";
|
|
142
|
+
import { embedNotice, embedState } from "./embed-model-command.mjs";
|
|
142
143
|
import {
|
|
143
144
|
MODELFALLBACK_PREFIX, fallbackAfterTurn, fallbackHistoryLine, fallbackNotice, keyMissingNotice,
|
|
144
145
|
parseModelFallback,
|
|
@@ -160,7 +161,7 @@ import {
|
|
|
160
161
|
registeredRange,
|
|
161
162
|
selectionKeyAction,
|
|
162
163
|
} from "./terminal-copy.mjs";
|
|
163
|
-
import { createHoverController, applyToggleShift } from "./terminal-hover.mjs";
|
|
164
|
+
import { createHoverController, applyToggleShift, rowsDiffer } from "./terminal-hover.mjs";
|
|
164
165
|
// The required pair, imported rather than re-typed: the startup "no models" message names them,
|
|
165
166
|
// and a second copy of these strings would drift from the check that enforces them.
|
|
166
167
|
import { ACCEPTED_EMBED_MODELS, REQUIRED_AGENT_MODEL, REQUIRED_AGENT_PREFIX } from "./ollama-setup.mjs";
|
|
@@ -847,7 +848,7 @@ if (!workerKey || !apiBase) {
|
|
|
847
848
|
console.error(red(`gu: setup failed: ${e instanceof Error ? e.message : e}`));
|
|
848
849
|
if (e && e.code === "PAIRING_UNSUPPORTED") {
|
|
849
850
|
console.error(
|
|
850
|
-
"Configure manually: gu-cli set <workerKey> --api-base https://chat-api-v2.
|
|
851
|
+
"Configure manually: gu-cli set <workerKey> --api-base https://chat-api-v2.getmarsic.cc"
|
|
851
852
|
);
|
|
852
853
|
}
|
|
853
854
|
if (e && (e.code === "KEY_REJECTED" || e.code === "KEY_UNCHECKED")) {
|
|
@@ -862,7 +863,7 @@ if (!workerKey || !apiBase) {
|
|
|
862
863
|
console.error(
|
|
863
864
|
red("gu: no worker key / API base found.") +
|
|
864
865
|
`\nRun ${accent("gu-cli login")} to sign in, or configure manually:` +
|
|
865
|
-
"\n gu-cli set <workerKey> --api-base https://chat-api-v2.
|
|
866
|
+
"\n gu-cli set <workerKey> --api-base https://chat-api-v2.getmarsic.cc"
|
|
866
867
|
);
|
|
867
868
|
process.exit(1);
|
|
868
869
|
}
|
|
@@ -1633,11 +1634,14 @@ let agentMissing = false; // no agent model chosen → agent falls back to the c
|
|
|
1633
1634
|
// question to. Seeded from the startup payload; re-checked before a question is refused.
|
|
1634
1635
|
let noModelAtAll = false;
|
|
1635
1636
|
let embedMissing = false; // no embed model chosen → RAG is off until /embed-model picks one
|
|
1637
|
+
// #347: WHY it is off — "unset" (none chosen) or "off" (one IS chosen, RAG is switched off) — and
|
|
1638
|
+
// the chosen model's name, so the row never says "no embed model" about a model that is set.
|
|
1639
|
+
let embedWhy = { state: "unset", model: "" };
|
|
1636
1640
|
// One footer row; the two halves are independent so a model that IS set is never named.
|
|
1637
1641
|
// ONE SHORT ROW EACH (#317): joined, they ran past 80 columns and were cut mid-word.
|
|
1638
1642
|
const missingNoticeRows = () => [
|
|
1639
1643
|
...(agentMissing ? ["⚠ agent model not set — using the coding model · /agent-model"] : []),
|
|
1640
|
-
...(embedMissing ? ["⚠ no embed model — RAG is off · /embed-model"] : []),
|
|
1644
|
+
...(embedMissing ? [embedNotice(embedWhy) || "⚠ no embed model — RAG is off · /embed-model"] : []),
|
|
1641
1645
|
];
|
|
1642
1646
|
|
|
1643
1647
|
// ANOTHER MODEL STOOD IN (task #343). The agent reports it as a __MODELFALLBACK__:: step; it is
|
|
@@ -1662,6 +1666,30 @@ const fallbackNoticeRows = () => {
|
|
|
1662
1666
|
];
|
|
1663
1667
|
};
|
|
1664
1668
|
|
|
1669
|
+
/**
|
|
1670
|
+
* Set the embed warning from what the settings SAY (task #347) — the one place it is decided
|
|
1671
|
+
* after startup's first guess.
|
|
1672
|
+
*
|
|
1673
|
+
* `repair`: an embedder is chosen but RAG is off. On a Local-mode machine the only way to choose
|
|
1674
|
+
* one is /embed-model or setup, and until this fix /embed-model never switched RAG on — so that
|
|
1675
|
+
* state is this bug's leftover, and it is finished here instead of asking the person to choose
|
|
1676
|
+
* again. In Cloud mode the web Settings has its own RAG switch; "off" may be a decision, so it is
|
|
1677
|
+
* reported, not changed.
|
|
1678
|
+
*/
|
|
1679
|
+
async function syncEmbedWarning({ repair = false } = {}) {
|
|
1680
|
+
const settings = await fetchWorkerSettings();
|
|
1681
|
+
if (!settings) return; // offline: keep what the bar already says
|
|
1682
|
+
let st = embedState(settings);
|
|
1683
|
+
if (st.state === "off" && repair && isClientExpert()) {
|
|
1684
|
+
const md = await import("./model-doctor.mjs");
|
|
1685
|
+
const saved = await md.saveWorkerSettings(apiBase, workerKey, { ragEnabled: true }).catch(() => false);
|
|
1686
|
+
if (saved) st = { ...st, state: "on" };
|
|
1687
|
+
}
|
|
1688
|
+
embedWhy = st;
|
|
1689
|
+
embedMissing = st.state !== "on";
|
|
1690
|
+
if (barPinned()) paintBar();
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1665
1693
|
// #307: the calm note after a quick change of mind — dim, not the ⚠ orange of a warning, and in the
|
|
1666
1694
|
// bar rather than the transcript (which the whole point is to leave untouched).
|
|
1667
1695
|
let stopNotice = "";
|
|
@@ -2577,12 +2605,18 @@ const tokenLineText = () => {
|
|
|
2577
2605
|
);
|
|
2578
2606
|
const total = dim(" · ") + dim("total ") + accent(`↓ ${fmtTokens(Math.max(globalOutputTokens, lastTurnTotalOutput))}`);
|
|
2579
2607
|
// #323: what the last turn's prompt cost, LAST on the row after a "|" — "~" when the cached share
|
|
2580
|
-
// was assumed;
|
|
2608
|
+
// was assumed; "no price" when the model has none (never a misleading "| $0"). It survives a
|
|
2581
2609
|
// narrow window: "total ↓" is dropped before it is (tokenRowWithPrice).
|
|
2582
2610
|
return tokenRowWithPrice({
|
|
2583
2611
|
withTotal: head + total,
|
|
2584
2612
|
withoutTotal: head,
|
|
2585
|
-
|
|
2613
|
+
// #349: a model with NO price says so ("no price", dim) — the blank it used to leave read as
|
|
2614
|
+
// the cost feature being broken. Still never "$0": unpriced is not free.
|
|
2615
|
+
price: lastTurnCost?.usd != null
|
|
2616
|
+
? dim(" | ") + accent(fmtCost(lastTurnCost))
|
|
2617
|
+
: lastTurnCost
|
|
2618
|
+
? dim(" | no price")
|
|
2619
|
+
: "",
|
|
2586
2620
|
room: (process.stdout.columns || 80) - 1 - visibleLen(GUTTER),
|
|
2587
2621
|
});
|
|
2588
2622
|
};
|
|
@@ -3348,6 +3382,7 @@ const hoverCtl = createHoverController({
|
|
|
3348
3382
|
pinned: () => barPinned(),
|
|
3349
3383
|
transcriptRows: () => transcriptRows(),
|
|
3350
3384
|
blockAtRow: (row) => thoughtBlockAtRow(row),
|
|
3385
|
+
canToggle: (b) => blockCanToggle(b),
|
|
3351
3386
|
paintRows: (b, on) => paintThoughtRows(b, on),
|
|
3352
3387
|
replaceRange: (from, to, rows) => transcript.replaceRange(from, to, rows),
|
|
3353
3388
|
repaintSeq: (b) => thoughtBlockRepaintSeq(b),
|
|
@@ -3646,9 +3681,20 @@ function copyTargetAtRow(row) {
|
|
|
3646
3681
|
return { block, renderedLines: lines.slice(from, to + 1) };
|
|
3647
3682
|
}
|
|
3648
3683
|
|
|
3684
|
+
/**
|
|
3685
|
+
* Would a click on this block change anything? (#350) — its collapsed and its expanded rows, laid
|
|
3686
|
+
* out for THIS window, are not the same. Asked by the hover (may it light up?), by the click and
|
|
3687
|
+
* by Ctrl+T, so the three can never disagree. See rowsDiffer in terminal-hover.mjs.
|
|
3688
|
+
*/
|
|
3689
|
+
function blockCanToggle(block) {
|
|
3690
|
+
if (!block) return false;
|
|
3691
|
+
return rowsDiffer(paintThoughtRows(block, false, true), paintThoughtRows(block, false, false));
|
|
3692
|
+
}
|
|
3693
|
+
|
|
3649
3694
|
function toggleThoughtAtRow(row) {
|
|
3650
3695
|
const block = thoughtBlockAtRow(row);
|
|
3651
|
-
|
|
3696
|
+
// A block shown whole has no other form: it did not light up, and the click is not for it.
|
|
3697
|
+
return block && blockCanToggle(block) ? toggleThoughtBlock(block) : false;
|
|
3652
3698
|
}
|
|
3653
3699
|
|
|
3654
3700
|
/**
|
|
@@ -3718,8 +3764,12 @@ function toggleThoughtBlock(block) {
|
|
|
3718
3764
|
|
|
3719
3765
|
/** Expand/collapse the most recently printed thought block. The keyboard path (Ctrl+T). */
|
|
3720
3766
|
function toggleNewestThought() {
|
|
3721
|
-
|
|
3722
|
-
|
|
3767
|
+
if (!barPinned()) return false;
|
|
3768
|
+
// The newest block that HAS another form (#350): Ctrl+T on a thought already shown whole did
|
|
3769
|
+
// nothing visible, while an expandable one sat just above it.
|
|
3770
|
+
let block = null;
|
|
3771
|
+
for (let i = thoughtBlocks.length - 1; i >= 0 && !block; i--) if (blockCanToggle(thoughtBlocks[i])) block = thoughtBlocks[i];
|
|
3772
|
+
if (!block) return false;
|
|
3723
3773
|
return toggleThoughtBlock(block);
|
|
3724
3774
|
}
|
|
3725
3775
|
|
|
@@ -4617,13 +4667,13 @@ const ask = async (q, { prefill = "", hint = [], atomic = false, noCancel = fals
|
|
|
4617
4667
|
rl.line = String(prefill);
|
|
4618
4668
|
rl.cursor = rl.line.length;
|
|
4619
4669
|
}
|
|
4620
|
-
// ELLIPSIS, so a truncation is VISIBLE. Silently cutting "…[https://ollama2.
|
|
4670
|
+
// ELLIPSIS, so a truncation is VISIBLE. Silently cutting "…[https://ollama2.getmarsic.cc/v1]:"
|
|
4621
4671
|
// to "…[https:/" hides the default Enter would accept, which is worse than not showing it —
|
|
4622
4672
|
// the reader cannot tell whether the value is short or the row lied. Callers should keep the
|
|
4623
4673
|
// final line short and put the long part above; this is the guard, not the plan.
|
|
4624
4674
|
// "… " with the space: the label normally ends in ": ", and a truncation that eats the
|
|
4625
4675
|
// separator leaves the typed text jammed against the last surviving word
|
|
4626
|
-
// ("agent + embed ser…ollama2.
|
|
4676
|
+
// ("agent + embed ser…ollama2.getmarsic.cc"). Two columns for the ellipsis and its space.
|
|
4627
4677
|
inlinePrompt =
|
|
4628
4678
|
(visibleLen(label) > budget ? clipVisible(label, budget - 2) + "… " : label) || PROMPT;
|
|
4629
4679
|
try {
|
|
@@ -5704,7 +5754,7 @@ async function reconfigureOllamaServer() {
|
|
|
5704
5754
|
const typed = (await ask(` ${short}: `)).trim();
|
|
5705
5755
|
// "s" is the advertised skip (#301); the older words still work.
|
|
5706
5756
|
if (ui.readStepAnswer(typed).kind === "skip") return null;
|
|
5707
|
-
// EVERY READING OF A BARE HOSTNAME, in order. Typing "ollama2.
|
|
5757
|
+
// EVERY READING OF A BARE HOSTNAME, in order. Typing "ollama2.getmarsic.cc" means
|
|
5708
5758
|
// https://…:443 behind a proxy, while "192.168.1.40" means http://…:11434 — one guess
|
|
5709
5759
|
// is right for one of them and produces a timeout that looks like a dead server for the
|
|
5710
5760
|
// other. See urlCandidates; the load decides, not the heuristic.
|
|
@@ -5785,7 +5835,7 @@ async function reconfigureOllamaServer() {
|
|
|
5785
5835
|
// ask again. Keep it, say where it is changed, and go on to the coder.
|
|
5786
5836
|
const agentOnProvider = am.agentIsOnProvider(settings);
|
|
5787
5837
|
const curAgent =
|
|
5788
|
-
String(settings.agentModelUrl ?? "").trim() || "https://ollama1.
|
|
5838
|
+
String(settings.agentModelUrl ?? "").trim() || "https://ollama1.getmarsic.cc/v1";
|
|
5789
5839
|
const curCode = am.coderServerDefault(settings);
|
|
5790
5840
|
|
|
5791
5841
|
let agent = null;
|
|
@@ -6704,7 +6754,9 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
|
|
|
6704
6754
|
}
|
|
6705
6755
|
if (role === "embed") {
|
|
6706
6756
|
// ragEmbedKeyId "" — the server's own keys decide now (its default, or this folder's pick).
|
|
6707
|
-
|
|
6757
|
+
// …and ragEnabled: true (#347): choosing an embedder IS turning RAG on. Without it the choice
|
|
6758
|
+
// was stored and never took effect, and the "no embed model" warning came back at every start.
|
|
6759
|
+
const fields = ec.embedSaveFields({ url: target.roleUrl("embed"), model });
|
|
6708
6760
|
if (!(await saveEmbedSettings(fields))) return;
|
|
6709
6761
|
if (plan) announceEmbedSaved({ chosen: model, plan, url: target.roleUrl("embed") });
|
|
6710
6762
|
else {
|
|
@@ -7849,6 +7901,7 @@ async function noteTurnCost(inputTokens, breakdown, coder = null) {
|
|
|
7849
7901
|
const c = mp.turnCost({ input: inputTokens, ...cache, price });
|
|
7850
7902
|
if (!c) {
|
|
7851
7903
|
lastTurnCost = { usd: null, model, host: mp.hostOf(url) };
|
|
7904
|
+
if (barPinned()) paintBar(); // #349: "no price" is shown, so it is painted like a cost is
|
|
7852
7905
|
return;
|
|
7853
7906
|
}
|
|
7854
7907
|
lastTurnCost = { ...c, input: inputTokens, cacheReported: cache.cacheReported, model, host: mp.hostOf(url) };
|
|
@@ -7891,7 +7944,7 @@ async function runPricesCommand(line = "/prices") {
|
|
|
7891
7944
|
console.log(GUTTER + dim(pricePublishedAt ? `from gu's tables published ${new Date(pricePublishedAt).toLocaleString()}` : "gu's built-in tables (none published applied yet)"));
|
|
7892
7945
|
if (priceUpdate) console.log(GUTTER + accent(`${mp.priceUpdateLabel(priceUpdate.publishedAt)} — click it below, or /prices update`));
|
|
7893
7946
|
if (!mp.findPrice(rows, url, model)) {
|
|
7894
|
-
|
|
7947
|
+
for (const r of wrapBlock(mp.noPriceLine({ model, host: mp.hostOf(url) }), GUTTER, width)) console.log(GUTTER + yellow(r));
|
|
7895
7948
|
}
|
|
7896
7949
|
if (lastTurnCost?.usd != null) {
|
|
7897
7950
|
const share = lastTurnCost.input ? Math.round((100 * lastTurnCost.cachedTokens) / lastTurnCost.input) : 0;
|
|
@@ -11088,6 +11141,7 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
11088
11141
|
// whether the user has clicked to expand it. Both reset when the step's Thought changes.
|
|
11089
11142
|
let fullLiveThought = "";
|
|
11090
11143
|
let thoughtExpanded = false;
|
|
11144
|
+
let liveExpandable = false; // the live thought line has more to show than fits (set each tick)
|
|
11091
11145
|
// Task #148: the CURRENT step's raw model output, accumulated in full (fenceThought stops at
|
|
11092
11146
|
// 400 chars — enough for a one-line label, not for the paragraphs we now print). Bounded so a
|
|
11093
11147
|
// runaway generation cannot grow without limit; the prose we keep is far shorter anyway.
|
|
@@ -11298,6 +11352,9 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
11298
11352
|
// "almost done" > "Thinking". When clickable we drive line 1 from the SAME fuller string, so
|
|
11299
11353
|
// the grey expansion CONTINUES where line 1 was cut off instead of repeating the head.
|
|
11300
11354
|
const clickable = !!fullLiveThought && !runningCmd && fullLiveThought.length > max;
|
|
11355
|
+
// #350: the click and Ctrl+T ask the SAME question the highlight does. They used to accept
|
|
11356
|
+
// any thought at all, so a line that was already whole took the click and redrew for nothing.
|
|
11357
|
+
liveExpandable = clickable;
|
|
11301
11358
|
let label, rest;
|
|
11302
11359
|
if (clickable) {
|
|
11303
11360
|
// Split at a WORD boundary near the line-1 width so nothing is cut mid-word: the last
|
|
@@ -11450,14 +11507,14 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
11450
11507
|
if (row < top || row > top + statusContentLines - LEAD_ROWS - WORD_GAP - 2) return;
|
|
11451
11508
|
// Only meaningful when there IS a fuller thought to reveal; otherwise ignore the click
|
|
11452
11509
|
// so we don't flicker an empty expand.
|
|
11453
|
-
if (!thoughtExpanded && !
|
|
11510
|
+
if (!thoughtExpanded && !liveExpandable) return;
|
|
11454
11511
|
toggleThought();
|
|
11455
11512
|
};
|
|
11456
11513
|
// Same expand/collapse, reachable without the mouse: Ctrl+T while a turn runs (see
|
|
11457
11514
|
// toggleActiveThought). Ignored when there's nothing more to reveal, so it never flickers an
|
|
11458
11515
|
// empty expansion.
|
|
11459
11516
|
const toggleThought = () => {
|
|
11460
|
-
if (!thoughtExpanded && !
|
|
11517
|
+
if (!thoughtExpanded && !liveExpandable) return;
|
|
11461
11518
|
thoughtExpanded = !thoughtExpanded;
|
|
11462
11519
|
clearStatus(); // next tick (≤120ms) redraws at the new size; keeps cursor math correct
|
|
11463
11520
|
};
|
|
@@ -12457,7 +12514,10 @@ async function main() {
|
|
|
12457
12514
|
// reaches the model over HTTP), so it runs HERE, where the files are, and calls the
|
|
12458
12515
|
// model machine for tokens. That needs a worker here like any other machine. The earlier
|
|
12459
12516
|
// build suppressed the daemon entirely, which is why this comment exists.
|
|
12460
|
-
|
|
12517
|
+
// GU_NO_WORKER_AUTOSTART=1 (task #351): the terminal UI tests play a scripted turn from a fake
|
|
12518
|
+
// API — no worker is needed, and one started for a throwaway home outlived the test (on
|
|
12519
|
+
// Windows it would be a scheduled task). Nothing a user sets; without it, as always:
|
|
12520
|
+
if (!status.running && process.env.GU_NO_WORKER_AUTOSTART !== "1") {
|
|
12461
12521
|
// NO LONGER A QUESTION (task #159, user 2026-08-09: "it need to run otherwise cannot work,
|
|
12462
12522
|
// so, just notify"). It used to ask, and a "no" was then remembered per folder — but the
|
|
12463
12523
|
// only workable answer was yes: without a worker nothing claims your turns, so a question
|
|
@@ -12613,7 +12673,12 @@ async function main() {
|
|
|
12613
12673
|
// private mode's local API can itself be unconfigured.
|
|
12614
12674
|
if (p.agentMissing === true) agentMissing = true;
|
|
12615
12675
|
noModelAtAll = nothingToAnswerWith(p);
|
|
12616
|
-
if (p.ragEnabled !== true)
|
|
12676
|
+
if (p.ragEnabled !== true) {
|
|
12677
|
+
embedMissing = true;
|
|
12678
|
+
// …but "off" has two causes, and only the settings can tell them apart (#347). In the
|
|
12679
|
+
// background: startup never waits on it, and the bar is repainted when the answer is in.
|
|
12680
|
+
void syncEmbedWarning({ repair: true });
|
|
12681
|
+
}
|
|
12617
12682
|
// A RESTORED PICK MUST STILL EXIST. The stored id names a model on the box this folder was
|
|
12618
12683
|
// last pointed at — and the box can change (`/model` → change the Ollama server), a model
|
|
12619
12684
|
// can be removed, or the folder can be opened on another machine entirely. Sending a name
|
|
@@ -12797,7 +12862,10 @@ async function main() {
|
|
|
12797
12862
|
// check used to sit INSIDE the "chat model is down" branch, so a client pointing at
|
|
12798
12863
|
// a healthy remote model was never offered them and every turn died with
|
|
12799
12864
|
// "No module named 'smolagents'". Independent now.
|
|
12800
|
-
|
|
12865
|
+
// GU_NO_AGENT_DEPS_PROMPT=1 (task #351): the terminal UI tests run no agent — on a machine
|
|
12866
|
+
// without the framework every test's throwaway home answered "yes" and sat through a pip
|
|
12867
|
+
// install (found on the Ubuntu server: 21 seconds of downloads, then a timeout).
|
|
12868
|
+
if (process.env.GU_NO_AGENT_DEPS_PROMPT !== "1" && !(await md.hasAgentLibs())) {
|
|
12801
12869
|
const skipDeps = await loadSessionSkip(resolve(process.cwd()), "skipDepsPrompt");
|
|
12802
12870
|
if (!skipDeps) {
|
|
12803
12871
|
console.log(
|
|
@@ -13338,7 +13406,12 @@ async function main() {
|
|
|
13338
13406
|
// TRANSCRIPT: the echo is written exactly once per submitted line and never repainted,
|
|
13339
13407
|
// so there is nothing here for a blank to accumulate against.
|
|
13340
13408
|
// As typed, not as sent: "[Image #1]", never the file path the agent needs (#51).
|
|
13341
|
-
|
|
13409
|
+
// Every row under the first one's text (#348): a pasted multi-line question used to be
|
|
13410
|
+
// echoed with only its first line indented, the rest hard against the left edge.
|
|
13411
|
+
const echo = questionEchoText(imageMarkersForDisplay(line), {
|
|
13412
|
+
width: process.stdout.columns || 80,
|
|
13413
|
+
paintPrompt: () => PROMPT_PAST,
|
|
13414
|
+
}) + "\n\n";
|
|
13342
13415
|
if (stopNotice) {
|
|
13343
13416
|
stopNotice = "";
|
|
13344
13417
|
if (stopNoticeTimer) clearTimeout(stopNoticeTimer);
|
|
@@ -13418,7 +13491,10 @@ async function main() {
|
|
|
13418
13491
|
}
|
|
13419
13492
|
if (line === "/embed-model") {
|
|
13420
13493
|
await chooseEmbedModel();
|
|
13421
|
-
|
|
13494
|
+
// Read back what is SAVED (#347). The warning used to be cleared here whatever happened —
|
|
13495
|
+
// a cancelled menu or a failed save removed it for the session, and it returned at the
|
|
13496
|
+
// next start as if the choice had been forgotten.
|
|
13497
|
+
await syncEmbedWarning({ repair: false });
|
|
13422
13498
|
continue;
|
|
13423
13499
|
}
|
|
13424
13500
|
if (line === "/server") {
|
package/model-prices.mjs
CHANGED
|
@@ -177,6 +177,33 @@ export function turnCost({ input = 0, cached = 0, cacheReported = false, firstIn
|
|
|
177
177
|
}
|
|
178
178
|
|
|
179
179
|
/** "$1.23", "$0.123", "$0.0042"; "~" in front when the cached share was assumed. */
|
|
180
|
+
/**
|
|
181
|
+
* WHAT THE BAR SAYS ABOUT THE LAST TURN'S COST (task #349).
|
|
182
|
+
*
|
|
183
|
+
* Reported: "price and cost is not shown on this gu terminal on this Mac." The coder there was a
|
|
184
|
+
* self-hosted Ollama model, which is in no price table — and for a model with no price the bar
|
|
185
|
+
* showed NOTHING, by design ("never a misleading | $0"). Right about the $0, wrong about the
|
|
186
|
+
* silence: a missing figure looked like a broken feature. So the three cases are told apart:
|
|
187
|
+
*
|
|
188
|
+
* a price was found → { kind: "cost", text: "$0.334" | "~$0.334" }
|
|
189
|
+
* the turn's model has none → { kind: "none", text: "no price" } — never "$0": it is not free,
|
|
190
|
+
* it is not priced
|
|
191
|
+
* no turn has run yet → null (there is nothing to say)
|
|
192
|
+
*
|
|
193
|
+
* `last` is the terminal's lastTurnCost: { usd, estimated } or { usd: null, model, host }.
|
|
194
|
+
*/
|
|
195
|
+
export function costSegment(last) {
|
|
196
|
+
if (!last) return null;
|
|
197
|
+
if (last.usd == null) return { kind: "none", text: "no price" };
|
|
198
|
+
return { kind: "cost", text: formatUsd(last.usd, { estimated: last.estimated === true }) };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** The line /prices prints when the coder in use has no price — why, and that nothing is broken. */
|
|
202
|
+
export function noPriceLine({ model = "", host = "" } = {}) {
|
|
203
|
+
const what = `${model || "the coder"}${host ? ` at ${host}` : ""}`;
|
|
204
|
+
return `no price for ${what} — its turns show "no price" on the bar instead of a cost. The table prices hosted providers; a model on your own server has none.`;
|
|
205
|
+
}
|
|
206
|
+
|
|
180
207
|
export function formatUsd(usd, { estimated = false } = {}) {
|
|
181
208
|
if (!Number.isFinite(usd)) return "";
|
|
182
209
|
const v = Math.max(0, usd);
|
package/model-setup-ui.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* THE WAY OUT OF THE MODEL QUESTIONS, and the key question that follows a typed server (#301).
|
|
3
3
|
*
|
|
4
|
-
* Reported: at "Agent + embedding server [https://ollama1.
|
|
4
|
+
* Reported: at "Agent + embedding server [https://ollama1.getmarsic.cc]:" there was no visible way
|
|
5
5
|
* to NOT choose. Enter takes the default server; a decline existed ("n", "no", "skip", "-") but no
|
|
6
6
|
* prompt said so, and one hint even claimed "press Enter with no address to skip", which was the
|
|
7
7
|
* opposite of what Enter does. Some people want no model setup at all and to load a saved preset.
|
package/ollama-setup.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Pointing a Client Expert machine at ITS OWN Ollama box (task #220).
|
|
3
3
|
*
|
|
4
|
-
* Setup used to seed https://ollama1.
|
|
4
|
+
* Setup used to seed https://ollama1.getmarsic.cc and three model names, so anyone whose box is
|
|
5
5
|
* somewhere else — a company LAN, http://192.168.1.40:11434, their own dnsname — had to know to
|
|
6
6
|
* override environment variables they had never heard of. This module is the pure half of asking
|
|
7
7
|
* instead: normalise whatever URL they type, and turn the list that box actually reports into the
|
|
@@ -180,8 +180,8 @@ export function describeNotReady(host, missing) {
|
|
|
180
180
|
* 192.168.1.40:11434 → http://192.168.1.40:11434/v1
|
|
181
181
|
* ollama.local → http://ollama.local:11434/v1
|
|
182
182
|
* http://box:11434/v1 → http://box:11434/v1
|
|
183
|
-
* https://ollama1.
|
|
184
|
-
* https://ollama1.
|
|
183
|
+
* https://ollama1.getmarsic.cc → https://ollama1.getmarsic.cc/v1
|
|
184
|
+
* https://ollama1.getmarsic.cc/v1/ → https://ollama1.getmarsic.cc/v1
|
|
185
185
|
*
|
|
186
186
|
* TWO DEFAULTS, AND THEY ARE NOT SYMMETRICAL:
|
|
187
187
|
*
|
|
@@ -243,7 +243,7 @@ export function normalizeOllamaUrl(raw) {
|
|
|
243
243
|
* Every address a bare hostname could reasonably mean, in the order worth trying.
|
|
244
244
|
*
|
|
245
245
|
* ONE GUESS IS NOT ENOUGH, and this was found by typing a real hostname at a real prompt:
|
|
246
|
-
* `ollama2.
|
|
246
|
+
* `ollama2.getmarsic.cc` became `http://ollama2.getmarsic.cc:11434` and timed out, because that
|
|
247
247
|
* box is behind a reverse proxy on HTTPS:443 — exactly like ollama1. The single default is
|
|
248
248
|
* right for a LAN box and wrong for a public name, and the reader has no way to know which
|
|
249
249
|
* assumption we made beyond a timeout that looks like their server is down.
|
|
@@ -390,7 +390,7 @@ export async function localOllamaDefault(fallbackUrl, fetchTags, { log } = {}) {
|
|
|
390
390
|
*
|
|
391
391
|
* DISPLAY ONLY. Every settings field in gu stores the /v1 base, because that is the
|
|
392
392
|
* OpenAI-compatible endpoint the API actually calls; nothing about what is SAVED changes here.
|
|
393
|
-
* What changes is what we show: a default of "https://ollama1.
|
|
393
|
+
* What changes is what we show: a default of "https://ollama1.getmarsic.cc/v1" reads as an
|
|
394
394
|
* instruction, and the reader is left wondering whether their own address needs the suffix too
|
|
395
395
|
* (reported 2026-09-04: "it makes user confused if he/she should input v1 to the url").
|
|
396
396
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tiens.nguyen/gu-cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.796",
|
|
4
4
|
"description": "Gu CLI — the gu terminal plus the local worker that runs agent / OCR / PDF / embedding jobs on your Mac (Ollama / MLX / OpenAI-compatible).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/proxy-config.mjs
CHANGED
|
@@ -126,8 +126,8 @@ export function isLoopbackUrl(url) {
|
|
|
126
126
|
/**
|
|
127
127
|
* The origin (scheme + host + port) of a URL, which is what "the same endpoint" means here.
|
|
128
128
|
*
|
|
129
|
-
* A configured model is https://ollama1.
|
|
130
|
-
* https://ollama1.
|
|
129
|
+
* A configured model is https://ollama1.getmarsic.cc/v1 while the actual call is
|
|
130
|
+
* https://ollama1.getmarsic.cc/v1/chat/completions — comparing full URLs would match neither, and
|
|
131
131
|
* comparing hostnames alone would proxy a DIFFERENT service that happens to share the host.
|
|
132
132
|
* Origin is the level that means "this endpoint", and URL normalizes the default port for us.
|
|
133
133
|
*/
|
|
@@ -141,7 +141,7 @@ export function originOf(url) {
|
|
|
141
141
|
|
|
142
142
|
/**
|
|
143
143
|
* The three model endpoints, read from the settings row rather than hardcoded — the user can
|
|
144
|
-
* change any of them at any time (/model, the web app), and a hardcoded ollama1.
|
|
144
|
+
* change any of them at any time (/model, the web app), and a hardcoded ollama1.getmarsic.cc
|
|
145
145
|
* would keep proxying an endpoint they have already moved away from.
|
|
146
146
|
*/
|
|
147
147
|
export function modelOrigins(settings = {}) {
|
package/terminal-hover.mjs
CHANGED
|
@@ -63,10 +63,38 @@ export function applyToggleShift(blocks, block, newEnd) {
|
|
|
63
63
|
* write(seq) → put bytes on screen.
|
|
64
64
|
* afterPaint() → put the caret back where the user is typing.
|
|
65
65
|
*/
|
|
66
|
+
/**
|
|
67
|
+
* DOES A CLICK ON THIS BLOCK CHANGE ANYTHING? (task #350) — the ONE question hover and click
|
|
68
|
+
* both ask.
|
|
69
|
+
*
|
|
70
|
+
* Reported: "hover an item and it becomes whiter; some items, you click and nothing expands — so
|
|
71
|
+
* the user is confused. Only allow hover to turn white if it is expandable." A highlight is a
|
|
72
|
+
* promise that a click does something. Every thought and every look-up row made that promise;
|
|
73
|
+
* a short thought that is already shown whole could not keep it.
|
|
74
|
+
*
|
|
75
|
+
* A block has two forms, collapsed and expanded. It can be toggled exactly when they are not the
|
|
76
|
+
* same rows — which answers every case without a list of kinds:
|
|
77
|
+
* · a long thought, collapsed → different rows → highlight, click expands
|
|
78
|
+
* · the same thought, expanded → different rows → highlight, click collapses
|
|
79
|
+
* · a thought that fits as it is → identical rows → no highlight, click does nothing
|
|
80
|
+
* · one look-up that fits its row → identical rows → no highlight
|
|
81
|
+
* …and it follows the WINDOW: the rows are laid out for the current width, so a thought that
|
|
82
|
+
* fits at 160 columns and is cut at 80 is expandable at 80 only.
|
|
83
|
+
*/
|
|
84
|
+
export function rowsDiffer(collapsedRows, expandedRows) {
|
|
85
|
+
const a = collapsedRows ?? [];
|
|
86
|
+
const b = expandedRows ?? [];
|
|
87
|
+
if (a.length !== b.length) return true;
|
|
88
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return true;
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
|
|
66
92
|
export function createHoverController({
|
|
67
93
|
pinned = () => true,
|
|
68
94
|
transcriptRows = () => 1,
|
|
69
95
|
blockAtRow = () => null,
|
|
96
|
+
// #350: may this block be highlighted — i.e. would a click on it change anything?
|
|
97
|
+
canToggle = () => true,
|
|
70
98
|
paintRows = () => [],
|
|
71
99
|
replaceRange = () => null,
|
|
72
100
|
repaintSeq = () => "",
|
|
@@ -101,7 +129,10 @@ export function createHoverController({
|
|
|
101
129
|
if (key === lastKey) return false;
|
|
102
130
|
lastKey = key;
|
|
103
131
|
|
|
104
|
-
|
|
132
|
+
// ONLY WHAT A CLICK WOULD CHANGE (#350). A block that is already shown whole is treated as
|
|
133
|
+
// no block at all: nothing lights up, and whatever was lit before is put back.
|
|
134
|
+
const under = blockAtRow(row);
|
|
135
|
+
const block = under && canToggle(under) ? under : null;
|
|
105
136
|
if (block === hovered) return false;
|
|
106
137
|
const previous = hovered;
|
|
107
138
|
hovered = block;
|
package/terminal-layout.mjs
CHANGED
|
@@ -528,6 +528,61 @@ export function buildFooterRows({
|
|
|
528
528
|
];
|
|
529
529
|
}
|
|
530
530
|
|
|
531
|
+
/**
|
|
532
|
+
* A SUBMITTED QUESTION, LAID OUT FOR THE TRANSCRIPT (task #348): every row at the column where
|
|
533
|
+
* the first one's text starts — after ">> ".
|
|
534
|
+
*
|
|
535
|
+
* Reported with a screenshot: a pasted question of many lines was echoed as ">> Build the first
|
|
536
|
+
* playable…" and then every other line hard against the left edge. The echo was the prompt plus
|
|
537
|
+
* the text exactly as typed, so only the first line had anything in front of it; a long line was
|
|
538
|
+
* left for the terminal to soft-wrap, back to column 0 as well.
|
|
539
|
+
*
|
|
540
|
+
* → rows WITHOUT the prompt: the caller puts ">> " before the first and `indent` spaces before
|
|
541
|
+
* the rest. Blank lines stay empty (no trailing spaces). A line's own leading spaces are kept —
|
|
542
|
+
* pasted code and nested lists keep their shape — and its wrapped rows start under its first
|
|
543
|
+
* word. A word longer than the row is cut rather than left to overflow.
|
|
544
|
+
*/
|
|
545
|
+
export function questionEchoRows(text, { width = 80, indent = 3 } = {}) {
|
|
546
|
+
const rows = [];
|
|
547
|
+
for (const line of String(text ?? "").replace(/\r/g, "").split("\n")) {
|
|
548
|
+
if (line.trim() === "") {
|
|
549
|
+
rows.push("");
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
const lead = /^[ \t]*/.exec(line)[0].replace(/\t/g, " ");
|
|
553
|
+
const avail = Math.max(8, (width || 80) - 1 - indent - visibleLen(lead));
|
|
554
|
+
let cur = "";
|
|
555
|
+
for (let word of line.trim().split(/\s+/)) {
|
|
556
|
+
while (visibleLen(word) > avail) {
|
|
557
|
+
if (cur) {
|
|
558
|
+
rows.push(lead + cur);
|
|
559
|
+
cur = "";
|
|
560
|
+
}
|
|
561
|
+
const head = clipVisible(word, avail);
|
|
562
|
+
rows.push(lead + head);
|
|
563
|
+
word = word.slice(head.length);
|
|
564
|
+
}
|
|
565
|
+
if (!word) continue;
|
|
566
|
+
const next = cur ? `${cur} ${word}` : word;
|
|
567
|
+
if (visibleLen(next) <= avail) cur = next;
|
|
568
|
+
else {
|
|
569
|
+
rows.push(lead + cur);
|
|
570
|
+
cur = word;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
if (cur) rows.push(lead + cur);
|
|
574
|
+
}
|
|
575
|
+
return rows;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** The echo itself: ">> " on the first row, the same width of spaces on every other. */
|
|
579
|
+
export function questionEchoText(text, { width = 80, prompt = ">> ", paintPrompt = (s) => s } = {}) {
|
|
580
|
+
const indent = visibleLen(prompt);
|
|
581
|
+
const rows = questionEchoRows(text, { width, indent });
|
|
582
|
+
if (rows.length === 0) return paintPrompt(prompt);
|
|
583
|
+
return rows.map((r, i) => (i === 0 ? paintPrompt(prompt) + r : r === "" ? "" : " ".repeat(indent) + r)).join("\n");
|
|
584
|
+
}
|
|
585
|
+
|
|
531
586
|
/**
|
|
532
587
|
* The bytes that print a NEW thought block into the transcript (task #344): its painted rows,
|
|
533
588
|
* with ONE empty line above when the row above is not already blank (`gap`).
|