@tiens.nguyen/gu-cli 1.0.779 → 1.0.781

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.
@@ -28,6 +28,7 @@ import json
28
28
  import os
29
29
  import random
30
30
  import re
31
+ from urllib.parse import urlparse
31
32
  import sys
32
33
  import threading
33
34
  import time
@@ -7506,6 +7507,31 @@ def _prepare_image(path):
7506
7507
  return im
7507
7508
 
7508
7509
 
7510
+ # WHICH CODERS MAY BE SENT IMAGES (#284, #316) — the SAME list the CLI gates Ctrl+V with
7511
+ # (image-capable-coders.json, beside this file), so the two can never disagree. A server/model is
7512
+ # added there only after a real request with an image came back right.
7513
+ def _image_coders():
7514
+ # GU_IMAGE_CODERS_FILE: tests only — a stand-in coder on 127.0.0.1 is put on the list there.
7515
+ path = os.environ.get("GU_IMAGE_CODERS_FILE") or os.path.join(
7516
+ os.path.dirname(os.path.abspath(__file__)), "image-capable-coders.json")
7517
+ try:
7518
+ with open(path, encoding="utf-8") as f:
7519
+ return json.load(f).get("coders") or []
7520
+ except Exception: # noqa: BLE001
7521
+ # No list → the one coder that has always taken images, rather than none or all.
7522
+ return [{"host": "api.anthropic.com"}]
7523
+
7524
+
7525
+ def _coder_takes_images(url, model, coders=None):
7526
+ """May the question's pasted images be sent to this coding server + model?"""
7527
+ host = (urlparse(url or "").hostname or "").lower().rstrip(".")
7528
+ name = str(model or "").split("::")[-1].strip()
7529
+ for c in (coders if coders is not None else _image_coders()):
7530
+ if c.get("host") == host and (not c.get("model") or re.search(c["model"], name, re.I)):
7531
+ return True
7532
+ return False
7533
+
7534
+
7509
7535
  def _load_turn_images(refs):
7510
7536
  """[(label, path)] → (PIL images ready for Claude, [(label, reason)] for the ones that failed)."""
7511
7537
  out, failed = [], []
@@ -13252,14 +13278,20 @@ def run_agent_chat(cfg):
13252
13278
  _RAG_STATE["s"] = "no-index"
13253
13279
  except Exception: # noqa: BLE001
13254
13280
  pass
13255
- # The question's pasted images (task #284): a Claude coder sees them; any other coder is
13256
- # told they were not sent rather than left to guess from a bare "[Image #1]".
13281
+ # The question's pasted images (task #284, #316): a coder on the shared image list (Claude,
13282
+ # Kimi K3 on Moonshot / Ollama cloud) sees them; any other coder is told they were not sent
13283
+ # rather than left to guess from a bare "[Image #1]".
13257
13284
  _turn_images = []
13258
13285
  if _turn_image_refs:
13259
- if _is_anthropic_url(coding_base_url):
13286
+ # The Anthropic check stays first: it is THE test of "this is Claude's native API" the
13287
+ # rest of the file uses (and the one test launchers point at a local stand-in).
13288
+ if _is_anthropic_url(coding_base_url) or _coder_takes_images(coding_base_url, coding_model_id):
13260
13289
  _turn_images, _img_failed = _load_turn_images(_turn_image_refs)
13261
13290
  task_with_hint = _label_failed_images(task_with_hint, _img_failed)
13262
- _log(f"attached {len(_turn_images)} image(s) to the question"
13291
+ # Content-free (#248): how many, and their sizes — never the pixels or the path.
13292
+ _log(f"attached {len(_turn_images)} image(s) to the question for "
13293
+ f"{(urlparse(coding_base_url or '').hostname or '?')} / {coding_model_id}"
13294
+ + ("" if not _turn_images else " — " + ", ".join(f"{im.width}x{im.height}" for im in _turn_images))
13263
13295
  + (f", {len(_img_failed)} could not be opened" if _img_failed else ""))
13264
13296
  for _lbl, _why in _img_failed:
13265
13297
  _emit({"type": "step", "text": f"⚠ {_lbl} not sent — {_why}"})
package/gu-repl.mjs CHANGED
@@ -26,7 +26,7 @@
26
26
  import { readFile, mkdir, writeFile, unlink, rename } from "node:fs/promises";
27
27
  import { readStepTokens } from "./step-tokens.mjs";
28
28
  import { snapshotInput, restoreInput, replaceStoppedQuestion, stopNoteLines } from "./turn-stop.mjs";
29
- import { existsSync, statSync, readFileSync, writeFileSync, readdirSync, openSync, readSync, closeSync } from "node:fs";
29
+ import { existsSync, statSync, readFileSync, writeFileSync, readdirSync, openSync, readSync, closeSync, mkdirSync } from "node:fs";
30
30
  import { spawn } from "node:child_process";
31
31
  // FIRST, before anything that could itself fail on an old runtime: npm only WARNS about
32
32
  // `engines`, so an unsupported Node installs fine and then breaks somewhere unrelated.
@@ -74,6 +74,7 @@ import {
74
74
  maskKey,
75
75
  otherAgentRows,
76
76
  updateRow,
77
+ noticeRow,
77
78
  updateNotice,
78
79
  answerBlock,
79
80
  updateRestartNote,
@@ -132,7 +133,7 @@ import { unapprovedProjectServers } from "./mcp-command.mjs";
132
133
  import { readHooks } from "./hooks-config.mjs";
133
134
  import { codingBannerLabel, isOllamaCloudUrl, SECRET_KEY_HINT } from "./coding-key-prompt.mjs";
134
135
  import { codingChoiceTags, coderDisplayName, coderWhere, whereOfActiveCoder } from "./coding-choice-tags.mjs";
135
- import { IMAGE_NEEDS_CLAUDE, clipboardImageToFile, fitImageNotice, imageMarkersForDisplay, createImageStore, imagePasteKey, imagePathFromPaste, isClaudeCoderUrl, isImagePasteKey, storeImageFile } from "./image-paste.mjs";
136
+ import { IMAGE_NEEDS_CLAUDE, coderTakesImagesFor, clipboardImageToFile, fitImageNotice, imageMarkersForDisplay, createImageStore, imagePasteKey, imagePathFromPaste, isClaudeCoderUrl, isImagePasteKey, storeImageFile } from "./image-paste.mjs";
136
137
  import {
137
138
  isDoubleClick,
138
139
  clickRun,
@@ -1612,7 +1613,8 @@ function showImageNotice(text) {
1612
1613
  if (barPinned()) paintBar();
1613
1614
  }
1614
1615
  /** Images go to the coding model; only a Claude coder takes them for now. */
1615
- const coderTakesImages = () => isClaudeCoderUrl(sessionCodingUrl || defaultCodingUrl);
1616
+ // #316: Claude, and Kimi K3 on a server verified to take images — one list, shared with the agent.
1617
+ const coderTakesImages = () => coderTakesImagesFor(sessionCodingUrl || defaultCodingUrl, sessionCodingModel || defaultCodingModel);
1616
1618
  function insertAtCaret(text) {
1617
1619
  dropSelectedInputText();
1618
1620
  const before = rl.line.slice(0, rl.cursor);
@@ -2604,6 +2606,28 @@ function syncBarPinning() {
2604
2606
  if (!canPinBar()) unpinBar();
2605
2607
  else pinBar();
2606
2608
  }
2609
+ /**
2610
+ * THE NOTICE ROW ABOVE THE SEPARATOR (#318) — or null when there is nothing to say.
2611
+ * Priority, highest first: where you are (jump to bottom), what you can act on with a click (an
2612
+ * update), a standing fact about the licence (the gu token notice — yellow only when urgent), and
2613
+ * other agents. Dropped whole, lowest first, when they do not all fit.
2614
+ */
2615
+ function zoneNotice(width) {
2616
+ const items = [];
2617
+ if (scrolledBack()) {
2618
+ const n = scrollOffset;
2619
+ items.push({ key: "jump", clickable: true, paint: accent, text: `↓ jump to bottom (click) · ${n} line${n === 1 ? "" : "s"} below` });
2620
+ }
2621
+ if (availableUpdates.any) {
2622
+ const up = updateRow({ ...availableUpdates, width: 400 });
2623
+ if (up) items.push({ key: "update", clickable: true, paint: accent, text: up.text.slice(up.from) });
2624
+ }
2625
+ if (tokenNotice?.text) items.push({ key: "token", paint: tokenNotice.urgent ? yellow : dim, text: tokenNotice.text });
2626
+ const agents = agentsIndicator(otherAgents);
2627
+ if (agents) items.push({ key: "agents", clickable: true, paint: accent, text: `${agents} running (click)` });
2628
+ return noticeRow(items, { width, gutter: GUTTER });
2629
+ }
2630
+
2607
2631
  /**
2608
2632
  * The whole frozen zone, top to bottom: a separator, a blank row, the INPUT LINE, then the
2609
2633
  * bar's rule, token and mode rows. The blank is what lifts the separator — and the control it
@@ -2624,17 +2648,12 @@ function frozenZone() {
2624
2648
  // #307: a question held until its turn prints anything sits on top of the status block — where
2625
2649
  // it will land once committed, so nothing moves when it does.
2626
2650
  const status = footerActive ? [] : [...pendingEchoRows(), ...liveStatusRows];
2627
- // DECIDED BEFORE THE LAYOUT, because the head's height depends on it: the blank under the
2628
- // separator exists to lift a CONTROL off the typed line, so with no control there is nothing
2629
- // to lift. The same three conditions the rows[sep] writes below test — kept adjacent so they
2630
- // cannot drift into disagreeing about whether a control is showing.
2631
- // The token notice counts only when it FITS — the same test its write below uses. With
2632
- // Boolean(tokenNotice) alone, a narrow terminal lifted a blank row for a line it never drew.
2633
- const tokenFits = Boolean(tokenNotice) && visibleLen(tokenNotice.text) + 4 <= width - 1;
2634
- const headControl =
2635
- scrolledBack() || availableUpdates.any || tokenFits || Boolean(agentsIndicator(otherAgents));
2651
+ // THE SEPARATOR CARRIES NOTHING (#318). What used to ride on it — jump-to-bottom, the update
2652
+ // control, the gu token notice, the agents count — is on its own NOTICE ROW above it; the head
2653
+ // is just the rule, with no blank to lift a control off the typed line.
2654
+ const notice = zoneNotice(width);
2636
2655
  const zone = inputZoneRows({
2637
- headControl,
2656
+ headControl: false,
2638
2657
  width,
2639
2658
  // #258: typing during a turn is not sent — the row says so (an ask() label still comes first).
2640
2659
  prompt: secretInput && secretPrompt
@@ -2679,61 +2698,14 @@ function frozenZone() {
2679
2698
  selection: secretInput ? null : inputSelection,
2680
2699
  highlight: highlightRow,
2681
2700
  });
2682
- const rows = status.concat(zone.rows, askHintRows(width), footerRows());
2683
- // THE SEPARATOR IS NO LONGER ROW 0. With the status block prepended it sits `status.length`
2684
- // rows down, and every affordance below writes to it by index — a bare rows[0] would paint
2685
- // the jump hint over the thought line and hit-test the wrong row.
2686
- const sep = status.length;
2687
- // THE SEPARATOR ROW carries at most one affordance, and the two contend for it:
2688
- //
2689
- // · scrolled back → "↓ jump to bottom", which WINS. It is transient and about where you are
2690
- // right now; an update is neither, and will still be there when you come back.
2691
- // · otherwise, and only when something is genuinely newer → the update control (task #181).
2692
- //
2693
- // Both are stored with their COLUMN span, because the row is hit-tested by column now — one
2694
- // row, two possible targets, and "anywhere on this row" would make the wrong one fire.
2695
- updateRowTarget = null;
2696
- agentsRowTarget = null;
2697
- if (scrolledBack()) {
2698
- const hint = jumpToBottomRow({ below: scrollOffset, width });
2699
- rows[sep] = clipVisible(accent(hint.text), width - 1);
2700
- } else if (availableUpdates.any) {
2701
- const up = updateRow({ ...availableUpdates, width });
2702
- if (up) {
2703
- rows[sep] = clipVisible(dim(up.text.slice(0, up.from)) + accent(up.text.slice(up.from)), width - 1);
2704
- updateRowTarget = { from: up.from, to: up.to };
2705
- }
2706
- } else if (tokenFits) {
2707
- // The gu token's one line (#232), right-aligned like the controls around it. BELOW the update
2708
- // control, because an update is actionable with a click and this is a standing fact; ABOVE the
2709
- // agents count, because it needs the user and that does not. Not clickable. Too narrow to say
2710
- // it whole → the condition above lets the agents count (or nothing) have the row instead.
2711
- const len = visibleLen(tokenNotice.text);
2712
- const from = width - 1 - len;
2713
- // Yellow only when the user must act (plan ended, over the computer limit); an old token is
2714
- // routine and reads in the rule's own dim grey (#292 — the yellow line "seems so serious").
2715
- const paint = tokenNotice.urgent ? yellow : dim;
2716
- rows[sep] = clipVisible(dim("\u2500".repeat(Math.max(0, from - 1)) + " ") + paint(tokenNotice.text), width - 1);
2717
- } else if (agentsIndicator(otherAgents)) {
2718
- // …and when there is no update to offer, the same row carries the agents count (task #195).
2719
- // THREE THINGS CANNOT SHARE ONE ROW, so they are ranked by how time-critical they are:
2720
- // the jump hint is about where you are RIGHT NOW, an update is a standing fact, and other
2721
- // agents change on their own. Update wins over agents because it is rarer and actionable;
2722
- // agents are always one click away through the same row once it is free.
2723
- const label = `${agentsIndicator(otherAgents)} running (click)`;
2724
- const len = visibleLen(label);
2725
- if (len + 4 <= width - 1) {
2726
- const from = width - 1 - len;
2727
- rows[sep] = clipVisible(dim("\u2500".repeat(Math.max(0, from - 1)) + " ") + accent(label), width - 1);
2728
- agentsRowTarget = { from, to: from + len };
2729
- }
2730
- }
2731
- // Spread, not a hand-written pick: this used to list the fields it forwarded, and adding
2732
- // rowCount/caretRow to inputZoneRows left them silently undefined here — the caret would have
2733
- // parked on the first row of a wrapped input forever.
2734
- // statusRows so paintBar can offset the input row and the separator's absolute position;
2735
- // without it every click and caret park would be `status.length` rows out.
2736
- return { ...zone, rows, statusRows: sep };
2701
+ const rows = status.concat(notice ? [notice.row] : [], zone.rows, askHintRows(width), footerRows());
2702
+ // The separator is `status.length (+1 for the notice row)` rows down — everything that writes to
2703
+ // or hit-tests a row by index counts from here.
2704
+ const sep = status.length + (notice ? 1 : 0);
2705
+ updateRowTarget = notice?.spans.update ?? null;
2706
+ agentsRowTarget = notice?.spans.agents ?? null;
2707
+ jumpSpan = notice?.spans.jump ?? null;
2708
+ return { ...zone, rows, statusRows: sep, noticeRow: Boolean(notice) };
2737
2709
  }
2738
2710
  /**
2739
2711
  * Put the output position back at column 0 of its own row — without printing anything.
@@ -2773,8 +2745,10 @@ function paintBar() {
2773
2745
  // The rows exactly as painted, so a double-click reads the same characters the user is
2774
2746
  // looking at rather than re-deriving them from rl.line at a width this may not be.
2775
2747
  inputRowTexts = zone.rows.slice((zone.statusRows ?? 0) + head);
2776
- jumpRowTarget = scrolledBack() ? { row: sepAbs } : null; // the SEPARATOR row
2777
- updateRowAbs = updateRowTarget || agentsRowTarget ? sepAbs : 0; // …the same row, when showing
2748
+ // THE NOTICE ROW is the one directly above the separator (#318) — never the separator itself.
2749
+ const noticeAbs = zone.noticeRow ? sepAbs - 1 : 0;
2750
+ jumpRowTarget = scrolledBack() && jumpSpan ? { row: noticeAbs, ...jumpSpan } : null;
2751
+ updateRowAbs = updateRowTarget || agentsRowTarget ? noticeAbs : 0;
2778
2752
  // preserveCaret:false — the save slot holds the OUTPUT position (see regionUi), and the caret
2779
2753
  // is parked on the input line right after, so there is nothing to preserve here.
2780
2754
  ui(
@@ -2928,6 +2902,8 @@ let liveStatusRows = [];
2928
2902
  // Where the "jump to bottom" line currently is (1-based viewport row + column span), so a click
2929
2903
  // can hit it. Null while the view is live.
2930
2904
  let jumpRowTarget = null;
2905
+ /** The jump-to-bottom hint's column span on the notice row (#318), set by frozenZone. */
2906
+ let jumpSpan = null;
2931
2907
  // The update control's column span on the separator row, and that row's absolute position.
2932
2908
  // Column-scoped (task #181): the row is shared with the jump-to-bottom hint, so a click has to
2933
2909
  // land ON the label rather than anywhere along it.
@@ -6858,12 +6834,56 @@ function withTerminalHandedOver(run) {
6858
6834
  * the API key by design (unlike /save-settings) — stored 0600 in the home folder, marked
6859
6835
  * "gu": "preset" so the two commands never offer each other's files.
6860
6836
  */
6837
+ /**
6838
+ * THIS ACCOUNT'S WHOLE SETUP as a version-2 preset (#320): every coding server and its models, the
6839
+ * default coder, the agent, the embedder — and each server's API key, read through the local API the
6840
+ * way the worker is handed one (GET /api/worker/coding-key?backendId). The keys stay in memory and in
6841
+ * the 0600 file; nothing prints them. → the preset, or null when the settings cannot be read.
6842
+ */
6843
+ async function buildFullPresetNow(name) {
6844
+ return (await import("./preset-apply.mjs")).buildFullPresetFrom(replPresetApi(), { name, host: hostname() });
6845
+ }
6846
+
6847
+ /** The local API, as preset-apply.mjs talks to it (#320) — the same calls the rest of the REPL makes. */
6848
+ function replPresetApi() {
6849
+ return {
6850
+ settings: () => fetchWorkerSettings(),
6851
+ keys: () => fetchCodingKeys(),
6852
+ keyFor: (backendId) => fetchKeyForBackend(backendId),
6853
+ saveSettings: async (patch) => (await import("./model-doctor.mjs")).saveWorkerSettings(apiBase, workerKey, patch),
6854
+ addKey: (name, key, rowId) => addCodingKey(name, key, rowId),
6855
+ setDefaultKey: (rowId, keyId) => setServerDefaultKey(rowId, keyId),
6856
+ };
6857
+ }
6858
+ const replPresetIo = {
6859
+ ask: (q) => ask(" " + q),
6860
+ say: (line, tone) =>
6861
+ console.log(GUTTER + (tone === "err" ? red : tone === "warn" ? yellow : tone === "ok" ? accent : dim)(" " + line)),
6862
+ };
6863
+
6864
+ /** The key a server would be handed ({ apiKey, keyId }), or null. Never logged. */
6865
+ async function fetchKeyForBackend(backendId) {
6866
+ try {
6867
+ const r = await fetch(`${apiBase}/api/worker/coding-key?backendId=${encodeURIComponent(backendId)}`, {
6868
+ headers: { "X-Worker-Key": workerKey },
6869
+ signal: AbortSignal.timeout(API_CALL_TIMEOUT_MS),
6870
+ });
6871
+ if (!r.ok) return null;
6872
+ const j = await r.json().catch(() => null);
6873
+ return typeof j?.apiKey === "string" && j.apiKey ? j : null;
6874
+ } catch {
6875
+ return null;
6876
+ }
6877
+ }
6878
+
6861
6879
  async function runSavePresetCommand(line) {
6862
6880
  const pr = await import("./presets-transfer.mjs");
6863
6881
  const home = homedir();
6864
6882
  console.log("");
6865
- const usage = "usage: /save-preset <name> (opens your editor) or /save-preset <name> {\"apiUrl\":\"…\",\"apiKey\":\"…\",\"agentModel\":\"…\",\"codingModel\":\"…\",\"embedModel\":\"…\"}";
6866
- let rest = line.trim().slice("/save-preset".length).trim().replace(/(^|\s)--edit(?=\s|$)/, " ").trim();
6883
+ const usage = "usage: /save-preset <name> (saves your whole setup) · /save-preset <name> --edit (in your editor) · /save-preset <name> {json}";
6884
+ const raw = line.trim().slice("/save-preset".length).trim();
6885
+ const wantsEdit = /(^|\s)--edit(?=\s|$)/.test(raw);
6886
+ let rest = raw.replace(/(^|\s)--edit(?=\s|$)/, " ").trim();
6867
6887
  if (!rest) {
6868
6888
  const typed = String((await ask("preset name: ")) ?? "").trim();
6869
6889
  if (!typed) {
@@ -6887,19 +6907,47 @@ async function runSavePresetCommand(line) {
6887
6907
  console.log(GUTTER + dim("server + api key + agent, coding and embedding models — the key is IN this file, keep it private."));
6888
6908
  console.log("");
6889
6909
  };
6910
+ // A WHOLE-SETUP preset (#320): written as given (dated, named), then summarised — keys masked.
6911
+ const storeFull = (doc) => {
6912
+ const out = { ...doc, gu: "preset", version: pr.FULL_PRESET_VERSION, name, savedAt: new Date().toISOString(), savedOn: hostname(),
6913
+ note: "The whole setup: every coding server and its models, the default coder, the agent and the embedder — and each server's API key. This file CONTAINS live API keys." };
6914
+ writeFileSync(file, JSON.stringify(out, null, 2) + "\n", { mode: 0o600 });
6915
+ const v = pr.validatePresetFile(JSON.stringify(out));
6916
+ console.log(GUTTER + `Preset "${name}" saved to ${file} — your whole setup:`);
6917
+ if (v.ok && v.full) for (const l of pr.fullPresetSummary(v.value, v.codingDefault)) console.log(GUTTER + dim(l));
6918
+ console.log(GUTTER + dim("It CONTAINS your API keys — keep it private. /load-presets → v to view it, c to copy it for another machine."));
6919
+ console.log("");
6920
+ };
6921
+ const ed = await import("./preset-editor.mjs");
6890
6922
  if (json) {
6891
- const checked = pr.validatePreset(json);
6923
+ const checked = ed.parseEdited(json);
6892
6924
  if (!checked.ok) {
6893
6925
  console.log(GUTTER + `Preset not saved — ${checked.problem}`);
6894
6926
  console.log("");
6895
6927
  return;
6896
6928
  }
6897
- store(checked.preset);
6929
+ if (checked.full) storeFull(checked.doc);
6930
+ else store(checked.preset);
6898
6931
  return;
6899
6932
  }
6900
6933
 
6901
- // THE EDITOR. Filled in from the preset being edited, else from what this account runs now.
6902
- const ed = await import("./preset-editor.mjs");
6934
+ // THE WHOLE SETUP, NOW (#320): every server, its models and key, the defaults — saved without an
6935
+ // editor, because "as it is now" is what a person saving their setup means. --edit opens it first.
6936
+ if (!wantsEdit) {
6937
+ if (existsSync(file) && !(await askYesNo(`A preset called "${name}" exists — replace it with the setup you have now?`, false))) {
6938
+ console.log(GUTTER + dim("Not saved.") + "\n");
6939
+ return;
6940
+ }
6941
+ const doc = await withPendingStatus("reading your servers, models and keys", () => buildFullPresetNow(name));
6942
+ if (!doc) {
6943
+ console.log(GUTTER + red(`couldn't read your settings from ${apiBase} — nothing was saved.`) + "\n");
6944
+ return;
6945
+ }
6946
+ storeFull(doc);
6947
+ return;
6948
+ }
6949
+
6950
+ // THE EDITOR. Filled in from the preset being edited, else from the whole setup this account has now.
6903
6951
  const editor = ed.pickEditor();
6904
6952
  if (!editor || !process.stdin.isTTY) {
6905
6953
  console.log(GUTTER + (editor ? "an editor needs a terminal — " : "no editor found (set $EDITOR, e.g. export EDITOR=nano) — ") + usage);
@@ -6915,7 +6963,7 @@ async function runSavePresetCommand(line) {
6915
6963
  /* unreadable — start from the current setup instead */
6916
6964
  }
6917
6965
  }
6918
- if (!start) start = ed.presetFromSettings((await fetchWorkerSettings()) ?? {});
6966
+ if (!start) start = (await buildFullPresetNow(name)) ?? ed.presetFromSettings((await fetchWorkerSettings()) ?? {});
6919
6967
  const initial = ed.editorText(name, start);
6920
6968
  console.log(GUTTER + dim(`${start.gu ? "editing" : "new"} preset "${name}" in ${basename(editor.cmd)} — save and quit to store it, quit without changes to cancel.`));
6921
6969
  let text = initial;
@@ -6932,7 +6980,8 @@ async function runSavePresetCommand(line) {
6932
6980
  }
6933
6981
  const parsed = ed.parseEdited(r.text);
6934
6982
  if (parsed.ok) {
6935
- store(parsed.preset);
6983
+ if (parsed.full) storeFull(parsed.doc);
6984
+ else store(parsed.preset);
6936
6985
  return;
6937
6986
  }
6938
6987
  console.log(GUTTER + yellow(`Not saved yet — ${parsed.problem}`));
@@ -7095,7 +7144,11 @@ async function presetFromPaste(pr, home) {
7095
7144
  if (!name) return "";
7096
7145
  const file = pr.presetFilePath(name, home);
7097
7146
  if (existsSync(file) && !(await askYesNo(`A preset called "${name}" exists — replace it?`, false))) return "";
7098
- writeFileSync(file, JSON.stringify(pr.buildPreset({ name, preset: parsed.preset, host: hostname() }), null, 2) + "\n", { mode: 0o600 });
7147
+ // A whole-setup preset (#320) is kept as it is — every server, key and default — named and dated.
7148
+ const out = parsed.full
7149
+ ? { ...parsed.doc, gu: "preset", version: pr.FULL_PRESET_VERSION, name, savedAt: new Date().toISOString(), savedOn: hostname() }
7150
+ : pr.buildPreset({ name, preset: parsed.preset, host: hostname() });
7151
+ writeFileSync(file, JSON.stringify(out, null, 2) + "\n", { mode: 0o600 });
7099
7152
  console.log(GUTTER + `Preset "${name}" saved to ${file}`);
7100
7153
  return file;
7101
7154
  } catch (e) {
@@ -7201,9 +7254,11 @@ async function runLoadPresetsCommand(line) {
7201
7254
  return;
7202
7255
  }
7203
7256
  const pname = shown.name || found[pick.index];
7204
- const command = ed.presetCopyCommand(pname, shown.preset);
7257
+ // A whole-setup preset (#320) is shown and copied AS SAVED — every server, every key.
7258
+ const body = shown.full ? JSON.parse(readFileSync(file, "utf8")) : shown.preset;
7259
+ const command = ed.presetCopyCommand(pname, body);
7205
7260
  if (pick.action === "view") {
7206
- const block = ed.presetBlock(pname, shown.preset, { width: process.stdout.columns || 80, gutter: GUTTER });
7261
+ const block = ed.presetBlock(pname, body, { width: process.stdout.columns || 80, gutter: GUTTER });
7207
7262
  const [top, ...restRows] = block.rows;
7208
7263
  const cut = block.button.from;
7209
7264
  console.log(dim(top.slice(0, cut)) + accent(top.slice(cut, block.button.to)) + dim(top.slice(block.button.to)));
@@ -7214,7 +7269,7 @@ async function runLoadPresetsCommand(line) {
7214
7269
  console.log("");
7215
7270
  return;
7216
7271
  }
7217
- const res = await writeClipboard(pick.action === "json" ? ed.presetCopyJson(shown.preset) : command);
7272
+ const res = await writeClipboard(pick.action === "json" ? ed.presetCopyJson(body) : command);
7218
7273
  console.log(
7219
7274
  GUTTER +
7220
7275
  (res.ok
@@ -7227,6 +7282,17 @@ async function runLoadPresetsCommand(line) {
7227
7282
  }
7228
7283
  }
7229
7284
  const checked = pr.validatePresetFile(readFileSync(file, "utf8"));
7285
+ if (checked.ok && checked.full) {
7286
+ const pname = checked.name || basename(file).replace(/\.json$/i, "");
7287
+ await applyTransferDoc(checked.value, {
7288
+ fromLine: `preset "${pname}" from ${file}${checked.savedOn ? ` (saved on ${checked.savedOn})` : ""} — the whole setup:`,
7289
+ summary: pr.fullPresetSummary(checked.value, checked.codingDefault),
7290
+ codingDefault: checked.codingDefault,
7291
+ warnings: checked.warnings,
7292
+ backupName: pname,
7293
+ });
7294
+ return;
7295
+ }
7230
7296
  if (!checked.ok) {
7231
7297
  console.log(GUTTER + `Not loaded — ${checked.problem}`);
7232
7298
  console.log("");
@@ -7372,58 +7438,29 @@ async function runLoadSettingsCommand(line) {
7372
7438
  console.log(GUTTER + dim(` file: ${file}\n`));
7373
7439
  return;
7374
7440
  }
7375
- const current = await withPendingStatus("reading your model settings", fetchWorkerSettings);
7376
- if (!current) {
7377
- console.log(GUTTER + red(` couldn't reach the API at ${apiBase} — nothing was changed.\n`));
7378
- return;
7379
- }
7380
- const keys = (await fetchCodingKeys()) ?? [];
7381
- const plan = st.planLoad(parsed.value, { ...current, keys });
7382
- console.log(GUTTER + dim(` from ${file}${parsed.value.savedOn ? ` (saved on ${parsed.value.savedOn})` : ""}`));
7383
- for (const l of plan.lines) console.log(GUTTER + dim(" " + l));
7384
- if (parsed.carriesSecrets) {
7385
- console.log(GUTTER + yellow(" this file contains API keys in plain text — delete it when you are done."));
7386
- }
7387
- console.log("");
7388
- const go = (await ask(" apply this? [y/N] ")).trim().toLowerCase();
7389
- if (go !== "y" && go !== "yes") {
7390
- console.log(GUTTER + dim(" nothing changed.\n"));
7391
- return;
7392
- }
7393
- const md = await import("./model-doctor.mjs");
7394
- // `current` so a provider this account already has keeps its row id — see toSettingsPatch.
7395
- const patch = st.toSettingsPatch(parsed.value, current);
7396
- const saved = Object.keys(patch).length
7397
- ? await md.saveWorkerSettings(apiBase, workerKey, patch)
7398
- : true;
7399
- if (!saved) {
7400
- console.log(GUTTER + red(" the API refused the settings — nothing was changed.\n"));
7401
- return;
7402
- }
7403
- console.log(GUTTER + accent(` applied: ${plan.adds} provider${plan.adds === 1 ? "" : "s"}${patch.ragEmbedUrl || patch.ragEmbedModel ? ", the embedding model" : ""}.`));
7441
+ await applyTransferDoc(parsed.value, {
7442
+ fromLine: `from ${file}${parsed.value.savedOn ? ` (saved on ${parsed.value.savedOn})` : ""}`,
7443
+ carriesSecrets: parsed.carriesSecrets,
7444
+ });
7445
+ }
7404
7446
 
7405
- // THE KEYS, AFTER the settings — each needs the row it belongs to, and the rows only exist
7406
- // once the API has minted their ids.
7407
- const fresh = (await fetchWorkerSettings()) ?? {};
7408
- const ps = await import("./coding-provider-setup.mjs");
7409
- const rows = ps.existingBackendRows(fresh);
7410
- // One server however it is spelled (task #245) — a raw compare missed "…/v1" and saved the key
7411
- // tied to NO server, where it can never be used.
7412
- const rowIdForUrl = (url) => ps.providerRowForUrl(rows, url)?.id ?? "";
7413
- for (const k of plan.missingKeys) {
7414
- const where = k.backendUrl ? ` for ${k.backendUrl}` : "";
7415
- const typed = k.key || (await ask(` key "${k.name}"${k.tail ? ` (…${k.tail})` : ""}${where} — paste it, or Enter to skip: `)).trim();
7416
- if (!typed) {
7417
- console.log(GUTTER + dim(` skipped ${k.name} — add it later with ctrl+k.`));
7418
- continue;
7419
- }
7420
- const rowId = k.backendUrl ? rowIdForUrl(k.backendUrl) : "";
7421
- const id = await addCodingKey(k.name, typed, rowId);
7422
- console.log(GUTTER + (id ? accent(` added ${k.name}.`) : red(` could not add ${k.name}.`)));
7423
- // The key that was its server's DEFAULT on the saving machine is the default here too.
7424
- if (id && rowId && k.default) await setServerDefaultKey(rowId, id);
7425
- }
7426
- console.log(GUTTER + dim(" pick a coding model with /model.\n"));
7447
+ /**
7448
+ * APPLY A SETTINGS DOCUMENT — /load-settings, and since #320 a whole-setup preset. One path, so a
7449
+ * preset gets exactly the guarantees the settings file earned: a server this account already has
7450
+ * keeps its row id (keys stay bound), each key lands on its own server, and the key that was its
7451
+ * server's default is the default here. A preset's keys carry their VALUES, so nothing is asked;
7452
+ * a settings file's keys do not, so each missing one is asked for. `codingDefault` (a preset) is
7453
+ * restored last, once its server's row exists.
7454
+ */
7455
+ async function applyTransferDoc(value, opts = {}) {
7456
+ const pa = await import("./preset-apply.mjs");
7457
+ return pa.applyTransfer(value, {
7458
+ ...opts,
7459
+ api: replPresetApi(),
7460
+ io: replPresetIo,
7461
+ backupDir: join(homedir(), ".gonext", "preset-backups"),
7462
+ host: hostname(),
7463
+ });
7427
7464
  }
7428
7465
 
7429
7466
  /** The .json files directly in a folder, with enough of each to recognise a settings file. */
@@ -0,0 +1,9 @@
1
+ {
2
+ "note": "Which coding models may be SENT pasted images (#284, #316). ONE list, read by the CLI (image-paste.mjs) and the agent (gonext_agent_chat.py) so the two can never disagree. Add a server/model ONLY after a real request with an image came back right (see #316's probe). host = the server's hostname exactly; model = a case-insensitive regular expression on the model name, or omitted for every model on that host.",
3
+ "coders": [
4
+ { "host": "api.anthropic.com", "why": "Claude — #284" },
5
+ { "host": "api.moonshot.ai", "model": "^kimi-k3(\\b|:|$)", "why": "Kimi K3 on Moonshot — verified 2026-09-30: answered 'Red circle' for a test PNG" },
6
+ { "host": "api.moonshot.cn", "model": "^kimi-k3(\\b|:|$)", "why": "Moonshot's China endpoint, same API as .ai" },
7
+ { "host": "ollama.com", "model": "^kimi-k3(\\b|:|$)", "why": "Kimi K3 on Ollama cloud — verified 2026-09-30: answered 'Red circle.' for a test PNG" }
8
+ ]
9
+ }
package/image-paste.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { readFileSync } from "node:fs";
1
2
  /**
2
3
  * Pasting an image into the gu input (task #284).
3
4
  *
@@ -64,7 +65,26 @@ const NOT_AN_IMAGE = "that file is not an image (png, jpg, gif or webp)";
64
65
 
65
66
  export const imagesDir = (home = homedir()) => join(home, ".gonext", "images");
66
67
 
67
- /** Is this coding server Claude's own API — the only one allowed images for now? */
68
+ /**
69
+ * WHICH CODERS MAY BE SENT IMAGES (#284, #316): the list in image-capable-coders.json, shared with
70
+ * the agent (gonext_agent_chat.py reads the same file), so the CLI's gate and the agent's can never
71
+ * disagree. A server/model is added there only after a real request with an image came back right.
72
+ */
73
+ export const IMAGE_CODERS = JSON.parse(readFileSync(new URL("./image-capable-coders.json", import.meta.url), "utf8")).coders;
74
+
75
+ /** May a pasted image be sent to this coder? `model` may be a qualified id ("openai::kimi-k3"). */
76
+ export function coderTakesImagesFor(url, model = "", coders = IMAGE_CODERS) {
77
+ let host = "";
78
+ try {
79
+ host = new URL(String(url ?? "")).hostname.toLowerCase().replace(/\.$/, "");
80
+ } catch {
81
+ return false;
82
+ }
83
+ const name = String(model ?? "").split("::").pop().trim();
84
+ return coders.some((c) => c.host === host && (!c.model || new RegExp(c.model, "i").test(name)));
85
+ }
86
+
87
+ /** Is this coding server Claude's own API? (Kept for callers that ask exactly that.) */
68
88
  export function isClaudeCoderUrl(url) {
69
89
  try {
70
90
  return new URL(String(url ?? "")).hostname.toLowerCase().replace(/\.$/, "") === "api.anthropic.com";
@@ -73,10 +93,11 @@ export function isClaudeCoderUrl(url) {
73
93
  }
74
94
  }
75
95
 
96
+ // The name is historical (#284 was Claude-only); since #316 Kimi K3 takes images too.
76
97
  export const IMAGE_NEEDS_CLAUDE =
77
- "image paste needs a Claude coding model — pick one with /model";
98
+ "image paste needs a coding model that takes images (Claude, or Kimi K3) — pick one with /model";
78
99
  /** The same, for a window too narrow for the full sentence: the fix (/model) must survive. */
79
- export const IMAGE_NEEDS_CLAUDE_SHORT = "images need a Claude coder — /model";
100
+ export const IMAGE_NEEDS_CLAUDE_SHORT = "images need Claude or Kimi K3 — /model";
80
101
 
81
102
  /**
82
103
  * The notice as it fits `cols` columns (#54): the full sentence, else its short form, else the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.779",
3
+ "version": "1.0.781",
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",
@@ -40,6 +40,7 @@
40
40
  "test:steps": "python3 scripts/py-tap.py tests/test_read_caps.py tests/test_trim_payback.py tests/test_explore.py tests/test_and_chains.py tests/test_edit_file_primary.py tests/test_bare_code_rescue.py tests/test_batching_rule.py tests/test_no_pdf_routing.py",
41
41
  "check:image": "node scripts/image-paste-check.mjs",
42
42
  "check:image-clipboard": "node scripts/image-paste-check.mjs --clipboard",
43
+ "test:presets": "node --test tests/preset-safety.test.mjs tests/preset-more.test.mjs tests/preset-apply.test.mjs tests/preset-full.test.mjs tests/presets-transfer.test.mjs tests/preset-editor.test.mjs tests/load-preset-paste.test.mjs",
43
44
  "test:skills": "python3 scripts/py-tap.py tests/test_skills_mcp_turn.py tests/test_hooks_turn.py tests/test_mcp_client.py && node --test tests/skills.test.mjs tests/mcp-config.test.mjs tests/hooks-config.test.mjs tests/slash-menu.test.mjs tests/conversation-archive.test.mjs",
44
45
  "capture:turn": "GONEXT_OLLAMA1_LIVE=1 node scripts/capture-turn-fixture.mjs",
45
46
  "test:bearer": "node --test tests/bearer-routing.test.mjs tests/bearer-verify.test.mjs",
@@ -65,7 +66,9 @@
65
66
  "credential-recovery.mjs",
66
67
  "settings-transfer.mjs",
67
68
  "presets-transfer.mjs",
69
+ "image-capable-coders.json",
68
70
  "preset-editor.mjs",
71
+ "preset-apply.mjs",
69
72
  "device-login.mjs",
70
73
  "daemon-control.mjs",
71
74
  "log-policy.mjs",
@@ -0,0 +1,156 @@
1
+ /**
2
+ * SAVING AND APPLYING A WHOLE SETUP — the steps themselves, with the local API, the question and the
3
+ * disk passed in (#320). It used to live inside gu-repl.mjs, where only "is it wired" could be tested;
4
+ * here the REAL code runs in tests against a fake API that records every call and can be told to
5
+ * fail — which is how "loading a preset cannot break the workspace" is checked rather than hoped.
6
+ *
7
+ * Used for /save-preset, /load-presets (a version-2 preset) and /load-settings — one path for all.
8
+ *
9
+ * api = {
10
+ * settings(): Promise<object|null> GET /api/worker/settings
11
+ * keys(): Promise<array|null> GET /api/worker/coding-keys (no values)
12
+ * keyFor(backendId): Promise<{apiKey,keyId}|null> the key a server would be handed
13
+ * saveSettings(patch): Promise<boolean> POST /api/worker/settings
14
+ * addKey(name, key, rowId): Promise<string> → the new key's id, or "" when refused
15
+ * setDefaultKey(rowId, id): Promise<boolean>
16
+ * }
17
+ * io = { ask(question) → Promise<string>, say(line, tone) } tone: "dim" | "warn" | "ok" | "err"
18
+ */
19
+ import { mkdirSync, writeFileSync } from "node:fs";
20
+ import { join } from "node:path";
21
+ import { buildFullPreset, keysToApply } from "./presets-transfer.mjs";
22
+ import { planLoad, toSettingsPatch } from "./settings-transfer.mjs";
23
+ import { existingBackendRows, providerRowForUrl } from "./coding-provider-setup.mjs";
24
+
25
+ /**
26
+ * THIS ACCOUNT'S WHOLE SETUP as a version-2 preset: every server, its models and the key it would
27
+ * be handed, the default coder, the agent, the embedder. → the preset, or null when unreadable.
28
+ */
29
+ export async function buildFullPresetFrom(api, { name, host = "", now = new Date() } = {}) {
30
+ const settings = await api.settings();
31
+ if (!settings) return null;
32
+ const keys = (await api.keys()) ?? [];
33
+ const rows = Array.isArray(settings.agentCodingBackends) ? settings.agentCodingBackends : [];
34
+ const keyValues = [];
35
+ for (const r of rows) {
36
+ if (!r?.id) continue;
37
+ const k = await api.keyFor(r.id);
38
+ if (k?.apiKey) keyValues.push({ backendId: r.id, keyId: k.keyId, value: k.apiKey });
39
+ }
40
+ return buildFullPreset({ name, settings, keys, keyValues, host, now });
41
+ }
42
+
43
+ /**
44
+ * Save the whole current setup before a preset replaces it. → { ok, file } | { ok: false, why }.
45
+ * Never throws: a backup that fails is a REFUSAL to apply, not a crash.
46
+ */
47
+ export async function backupSetup(api, presetName, { dir, host = "", now = new Date(), fs = { mkdirSync, writeFileSync } } = {}) {
48
+ try {
49
+ const doc = await buildFullPresetFrom(api, { name: `before-${presetName}`, host, now });
50
+ if (!doc) return { ok: false, why: "the settings could not be read" };
51
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
52
+ const stamp = new Date(now).toISOString().replace(/[:.]/g, "-");
53
+ const file = join(dir, `before-${String(presetName).replace(/[^\w.-]+/g, "_")}-${stamp}.json`);
54
+ fs.writeFileSync(file, JSON.stringify({ ...doc, savedAt: new Date(now).toISOString(), savedOn: host }, null, 2) + "\n", { mode: 0o600 });
55
+ return { ok: true, file };
56
+ } catch (e) {
57
+ return { ok: false, why: e?.message ?? String(e) };
58
+ }
59
+ }
60
+
61
+ /**
62
+ * APPLY A SETTINGS DOCUMENT — a whole-setup preset, or a /save-settings file. In order, each step only
63
+ * if the one before it succeeded:
64
+ * 1. read the account; show the plan (servers, models, masked keys, warnings);
65
+ * 2. ask — anything but y/yes changes NOTHING;
66
+ * 3. back up the current setup (when `backupName`) — no backup, no apply;
67
+ * 4. one settings write: the servers (rows this account has keep their ids), agent, embedder;
68
+ * 5. the keys: carried ones added to their server, ones already there kept (and made default when the
69
+ * preset says so), a settings file's missing ones asked for;
70
+ * 6. the default coder, once its server's row exists.
71
+ * → { applied: boolean, step?: where it stopped, backup?: file, added: [names], failed: [names] }
72
+ */
73
+ export async function applyTransfer(value, { api, io, fromLine = "", carriesSecrets = false, summary = null, codingDefault = null, warnings = [], backupName = "", backupDir = "", host = "", now = new Date(), fs } = {}) {
74
+ const say = (line, tone = "dim") => io.say(line, tone);
75
+ const out = { applied: false, added: [], failed: [] };
76
+ const current = await api.settings();
77
+ if (!current) {
78
+ say("couldn't reach the API — nothing was changed.", "err");
79
+ return { ...out, step: "read" };
80
+ }
81
+ const keys = (await api.keys()) ?? [];
82
+ const plan = planLoad(value, { ...current, keys });
83
+ if (fromLine) say(fromLine);
84
+ const lines = summary
85
+ ? [...summary, ...plan.lines.filter((l) => /^(agent model|embedding model| a different embedder)/.test(l))]
86
+ : plan.lines;
87
+ for (const l of lines) say(l);
88
+ if (summary && plan.replaces) say(`replaces the ${plan.replaces} coding provider${plan.replaces === 1 ? "" : "s"} on this account.`);
89
+ for (const w of warnings) say("⚠ " + w, "warn");
90
+ if (carriesSecrets) say("this file contains API keys in plain text — delete it when you are done.", "warn");
91
+ const go = String((await io.ask("apply this? [y/N] ")) ?? "").trim().toLowerCase();
92
+ if (go !== "y" && go !== "yes") {
93
+ say("nothing changed.");
94
+ return { ...out, step: "declined" };
95
+ }
96
+ if (backupName) {
97
+ const b = await backupSetup(api, backupName, { dir: backupDir, host, now, fs });
98
+ if (!b.ok) {
99
+ say(`couldn't save a backup of your current setup (${b.why}) — nothing was changed.`, "err");
100
+ return { ...out, step: "backup" };
101
+ }
102
+ out.backup = b.file;
103
+ say(`backup of your current setup: ${b.file}`);
104
+ say(`to undo: /load-presets ${b.file}`);
105
+ }
106
+ const patch = toSettingsPatch(value, current);
107
+ const saved = Object.keys(patch).length ? await api.saveSettings(patch) : true;
108
+ if (!saved) {
109
+ say("the API refused the settings — nothing was changed.", "err");
110
+ return { ...out, step: "settings" };
111
+ }
112
+ out.applied = true;
113
+ say(`applied: ${plan.adds} provider${plan.adds === 1 ? "" : "s"}${patch.ragEmbedUrl || patch.ragEmbedModel ? ", the embedding model" : ""}.`, "ok");
114
+
115
+ const fresh = (await api.settings()) ?? {};
116
+ const rows = existingBackendRows(fresh);
117
+ const { add, kept } = keysToApply(value, { currentKeys: keys, rows, missing: plan.missingKeys });
118
+ for (const k of kept) {
119
+ say(`kept your existing key ${k.name} for ${k.backendUrl} (the preset's copy was not used).`);
120
+ if (k.default && k.rowId && k.existingId && !(await api.setDefaultKey(k.rowId, k.existingId))) {
121
+ say(`could not make ${k.name} the default for ${k.backendUrl} — pick it in /model with ctrl+k.`, "err");
122
+ }
123
+ }
124
+ for (const k of add) {
125
+ const where = k.backendUrl ? ` for ${k.backendUrl}` : "";
126
+ const typed = k.key || String((await io.ask(`key "${k.name}"${k.tail ? ` (…${k.tail})` : ""}${where} — paste it, or Enter to skip: `)) ?? "").trim();
127
+ if (!typed) {
128
+ say(`skipped ${k.name} — add it later with ctrl+k.`);
129
+ continue;
130
+ }
131
+ const id = await api.addKey(k.name, typed, k.rowId);
132
+ if (!id) {
133
+ out.failed.push(k.name);
134
+ say(`could not add ${k.name}${where} — add it in /model with ctrl+k.`, "err");
135
+ continue;
136
+ }
137
+ out.added.push(k.name);
138
+ say(`added ${k.name}.`, "ok");
139
+ if (k.rowId && k.default && !(await api.setDefaultKey(k.rowId, id))) {
140
+ say(`added ${k.name}, but could not make it the default for ${k.backendUrl} — pick it in /model with ctrl+k.`, "err");
141
+ }
142
+ }
143
+ if (codingDefault?.url && codingDefault?.model) {
144
+ const row = providerRowForUrl(rows, codingDefault.url)?.id ?? "";
145
+ const ok = await api.saveSettings({
146
+ agentCodingModelUrl: codingDefault.url,
147
+ agentCodingModelName: codingDefault.model,
148
+ ...(codingDefault.kind ? { agentCodingModelKind: codingDefault.kind } : {}),
149
+ ...(row ? { agentCodingDefaultBackendId: row } : {}),
150
+ });
151
+ say(ok ? `coding model: ${codingDefault.model} at ${codingDefault.url}.` : "could not set the coding model — pick it with /model.", ok ? "ok" : "err");
152
+ return { ...out, step: "done", coder: ok };
153
+ }
154
+ say("pick a coding model with /model.");
155
+ return { ...out, step: "done" };
156
+ }
package/preset-editor.mjs CHANGED
@@ -23,7 +23,10 @@ import { spawnSync } from "node:child_process";
23
23
  import { mkdtempSync, writeFileSync, readFileSync, rmSync, chmodSync } from "node:fs";
24
24
  import { tmpdir } from "node:os";
25
25
  import { join } from "node:path";
26
- import { validatePreset } from "./presets-transfer.mjs";
26
+ import { validatePreset, validatePresetFile, maskedFullPreset, FULL_PRESET_VERSION } from "./presets-transfer.mjs";
27
+
28
+ /** A version-2 preset — the WHOLE setup (#320): every server, keys, defaults. */
29
+ export const isFullPreset = (doc) => Boolean(doc && typeof doc === "object" && (doc.version === FULL_PRESET_VERSION || Array.isArray(doc.codingBackends)));
27
30
 
28
31
  const clean = (v) => (typeof v === "string" ? v.trim() : "");
29
32
 
@@ -80,6 +83,19 @@ const FIELDS = ["apiUrl", "apiKey", "agentModel", "codingModel", "embedModel", "
80
83
 
81
84
  /** The text the editor opens with: a few `//` lines saying what to do, then the JSON. */
82
85
  export function editorText(name, preset = {}) {
86
+ if (isFullPreset(preset)) {
87
+ const { note: _n, savedAt: _a, savedOn: _o, ...body } = preset;
88
+ return (
89
+ [
90
+ `// gu preset "${name}" — THE WHOLE SETUP: every coding server and its models, each server's API key,`,
91
+ "// the default coder, the agent and the embedder. Edit anything, then save and quit to store it.",
92
+ "// Lines starting with // are ignored. Quit without saving (or unchanged) to cancel. This CONTAINS your API keys.",
93
+ ].join("\n") +
94
+ "\n" +
95
+ JSON.stringify({ ...body, gu: "preset", version: FULL_PRESET_VERSION, name }, null, 2) +
96
+ "\n"
97
+ );
98
+ }
83
99
  const body = {};
84
100
  for (const f of FIELDS) body[f] = clean(preset[f]);
85
101
  return (
@@ -94,6 +110,77 @@ export function editorText(name, preset = {}) {
94
110
  );
95
111
  }
96
112
 
113
+ /**
114
+ * Where JSON text first goes wrong (an index), or -1 when it is valid — a small strict parser used only
115
+ * to NAME the spot of a mistake when Node's own message does not.
116
+ */
117
+ export function jsonErrorIndex(src) {
118
+ let i = 0;
119
+ const ws = () => { while (i < src.length && " \t\r\n".includes(src[i])) i++; };
120
+ const fail = () => { throw i; };
121
+ const lit = (w) => { if (src.startsWith(w, i)) i += w.length; else fail(); };
122
+ const str = () => {
123
+ i++;
124
+ while (i < src.length && src[i] !== '"') {
125
+ if (src[i] === "\\") {
126
+ // only JSON's escapes: \" \\ \/ \b \f \n \r \t and \uXXXX
127
+ const e = src[i + 1];
128
+ if (e === "u") {
129
+ if (!/^[0-9a-fA-F]{4}$/.test(src.slice(i + 2, i + 6))) { i += 1; fail(); }
130
+ i += 6;
131
+ continue;
132
+ }
133
+ if (!e || !'"\\/bfnrt'.includes(e)) { i += 1; fail(); }
134
+ i += 2;
135
+ continue;
136
+ }
137
+ if (src.charCodeAt(i) < 0x20) fail();
138
+ i++;
139
+ }
140
+ if (src[i] !== '"') fail();
141
+ i++;
142
+ };
143
+ const num = () => {
144
+ const m = /^-?(0|[1-9]\d*)(\.\d+)?([eE][+-]?\d+)?/.exec(src.slice(i));
145
+ if (!m) fail();
146
+ i += m[0].length;
147
+ };
148
+ const val = () => {
149
+ ws();
150
+ const c = src[i];
151
+ if (c === "{") {
152
+ i++; ws();
153
+ if (src[i] === "}") { i++; return; }
154
+ for (;;) {
155
+ ws(); if (src[i] !== '"') fail(); str(); ws();
156
+ if (src[i] !== ":") fail(); i++; val(); ws();
157
+ if (src[i] === ",") { i++; continue; }
158
+ if (src[i] === "}") { i++; return; }
159
+ fail();
160
+ }
161
+ } else if (c === "[") {
162
+ i++; ws();
163
+ if (src[i] === "]") { i++; return; }
164
+ for (;;) {
165
+ val(); ws();
166
+ if (src[i] === ",") { i++; continue; }
167
+ if (src[i] === "]") { i++; return; }
168
+ fail();
169
+ }
170
+ } else if (c === '"') str();
171
+ else if (c === "t") lit("true");
172
+ else if (c === "f") lit("false");
173
+ else if (c === "n") lit("null");
174
+ else num();
175
+ };
176
+ try {
177
+ val(); ws();
178
+ return i < src.length ? i : -1;
179
+ } catch (at) {
180
+ return typeof at === "number" ? at : 0;
181
+ }
182
+ }
183
+
97
184
  /**
98
185
  * What came back from the editor → { ok, preset } | { ok: false, problem }. The // lines are
99
186
  * blanked (not removed) so a JSON error's line number is the line the person sees in the editor.
@@ -104,10 +191,25 @@ export function parseEdited(text) {
104
191
  try {
105
192
  JSON.parse(src);
106
193
  } catch (e) {
107
- const at = /line (\d+) column (\d+)/.exec(String(e?.message ?? ""));
194
+ // Node names the line only for SOME errors ("Expected ':' …"); a stray comma comes back as just a
195
+ // snippet. So when it does not say, find the spot ourselves — the person fixes a LINE in an editor.
196
+ let at = /line (\d+) column (\d+)/.exec(String(e?.message ?? ""));
197
+ if (!at) {
198
+ const i = jsonErrorIndex(src);
199
+ if (i >= 0) {
200
+ const before = src.slice(0, i).split("\n");
201
+ at = [null, String(before.length), String(before[before.length - 1].length + 1)];
202
+ }
203
+ }
108
204
  const where = at ? ` (line ${at[1]}, column ${at[2]})` : "";
109
205
  return { ok: false, problem: `that is not valid JSON${where} — ${String(e?.message ?? e).replace(/\s*in JSON at position \d+.*$|\s*\(line \d+ column \d+\)/g, "")}.` };
110
206
  }
207
+ const obj = JSON.parse(src);
208
+ if (isFullPreset(obj)) {
209
+ const doc = { ...obj, gu: "preset", version: FULL_PRESET_VERSION };
210
+ const v = validatePresetFile(JSON.stringify(doc));
211
+ return v.ok ? { ok: true, full: true, doc, checked: v } : v;
212
+ }
111
213
  return validatePreset(src);
112
214
  }
113
215
 
@@ -147,14 +249,20 @@ const presetBody = (preset) => {
147
249
  return o;
148
250
  };
149
251
 
150
- /** What "copy" puts on the clipboard: the command that recreates the preset — WITH the key. */
252
+ /** A whole-setup preset without its bookkeeping (note, dates) — what is copied and edited. */
253
+ const fullBody = (doc) => {
254
+ const { note: _n, savedAt: _a, savedOn: _o, ...rest } = doc;
255
+ return rest;
256
+ };
257
+
258
+ /** What "copy" puts on the clipboard: the command that recreates the preset — WITH the key(s). */
151
259
  export function presetCopyCommand(name, preset) {
152
- return `/save-preset ${clean(name)} ${JSON.stringify(presetBody(preset))}`;
260
+ return `/save-preset ${clean(name)} ${JSON.stringify(isFullPreset(preset) ? fullBody(preset) : presetBody(preset))}`;
153
261
  }
154
262
 
155
- /** …or just the JSON, pretty, WITH the key. */
263
+ /** …or just the JSON, pretty, WITH the key(s). */
156
264
  export function presetCopyJson(preset) {
157
- return JSON.stringify(presetBody(preset), null, 2);
265
+ return JSON.stringify(isFullPreset(preset) ? fullBody(preset) : presetBody(preset), null, 2);
158
266
  }
159
267
 
160
268
  /**
@@ -163,7 +271,7 @@ export function presetCopyJson(preset) {
163
271
  * → { rows, button: { from, to } } — the button's column span on row 0, 0-based, gutter included.
164
272
  */
165
273
  export function presetBlock(name, preset, { width = 80, gutter = " " } = {}) {
166
- const shown = { ...presetBody(preset), apiKey: maskKey(preset.apiKey) };
274
+ const shown = isFullPreset(preset) ? fullBody(maskedFullPreset(preset)) : { ...presetBody(preset), apiKey: maskKey(preset.apiKey) };
167
275
  const inner = JSON.stringify(shown, null, 2).split("\n");
168
276
  const room = Math.max(24, width - 1 - gutter.length);
169
277
  const innerW = Math.min(room - 4, Math.max(...inner.map((l) => [...l].length), 30));
@@ -211,7 +319,8 @@ export function parsePastedPreset(text) {
211
319
  }
212
320
  const parsed = parseEdited(src);
213
321
  if (!parsed.ok) return parsed;
214
- // A whole preset file carries its name inside; the command's own name wins when both exist.
322
+ // A whole-setup preset (#320) or a preset file carries its name inside; the command's name wins.
323
+ if (parsed.full) return { ok: true, full: true, doc: parsed.doc, name: name || clean(parsed.doc.name) };
215
324
  try {
216
325
  const doc = JSON.parse(src.replace(/^[ \t]*\/\/.*$/gm, ""));
217
326
  if (!name && doc && typeof doc.name === "string" && doc.gu === "preset") name = doc.name.trim();
@@ -20,7 +20,8 @@
20
20
  */
21
21
 
22
22
  import { basename, extname, isAbsolute, join, resolve } from "node:path";
23
- import { ensureV1Root, isCancelWord, MAX_MODELS_PER_SERVER } from "./coding-provider-setup.mjs";
23
+ import { ensureV1Root, isCancelWord, MAX_MODELS_PER_SERVER, providerRowForUrl } from "./coding-provider-setup.mjs";
24
+ import { buildTransfer, validateTransfer, FILE_VERSION as SETTINGS_FILE_VERSION } from "./settings-transfer.mjs";
24
25
 
25
26
  /** The file name used when none is given — in the default folder (the home directory). */
26
27
  export const DEFAULT_PRESET_NAME = "gu-preset.json";
@@ -150,6 +151,7 @@ export function validatePresetFile(text) {
150
151
  if (!doc || typeof doc !== "object" || Array.isArray(doc) || doc.gu !== "preset") {
151
152
  return { ok: false, problem: 'that file is not a gu preset file (no "gu": "preset" marker).' };
152
153
  }
154
+ if (doc.version === FULL_PRESET_VERSION) return validateFullPreset(doc);
153
155
  if (doc.version !== PRESET_FILE_VERSION) {
154
156
  return {
155
157
  ok: false,
@@ -213,3 +215,208 @@ export function presetPlanLines(name, preset, current = {}) {
213
215
  lines.push(" replaces the coding provider list with this one server.");
214
216
  return lines;
215
217
  }
218
+
219
+ // ---------------------------------------------------------------------------------------------
220
+ // THE WHOLE SETUP (#320: "save/load presets lack all the API URLs I have set up … I need to see and
221
+ // copy all, including the API key, to another machine, so I can use it without creating the
222
+ // providers again or choosing the models again").
223
+ //
224
+ // A version-2 preset is the /save-settings document (every coding server with its models, the agent
225
+ // model, the embedder — settings-transfer.mjs) PLUS what that file refuses by design: each server's
226
+ // API key, in `keys[].key`, and the default coding model. It is applied with the very same code as
227
+ // /load-settings, so a server this account already has keeps its row id and each key lands on its
228
+ // own server as that server's default. Version 1 (one server) still loads, unchanged.
229
+ // ---------------------------------------------------------------------------------------------
230
+
231
+ export const FULL_PRESET_VERSION = 2;
232
+
233
+ /**
234
+ * Build a version-2 preset. `settings` = GET /api/worker/settings; `keys` = the masked key list
235
+ * (id, name, backendId, default); `keyValues` = [{ backendId, keyId?, value }] — each server's key as
236
+ * the worker would be handed it. A server with no readable key simply carries none.
237
+ */
238
+ export function buildFullPreset({ name, settings = {}, keys = [], keyValues = [], now = new Date(), host = "" } = {}) {
239
+ const t = buildTransfer({ settings, keys, now, host });
240
+ const rows = Array.isArray(settings.agentCodingBackends) ? settings.agentCodingBackends : [];
241
+ const urlOf = (id) => clean(rows.find((r) => r?.id === id)?.url);
242
+ const list = Array.isArray(keys) ? keys : [];
243
+ const out = [];
244
+ for (const v of Array.isArray(keyValues) ? keyValues : []) {
245
+ const url = urlOf(v.backendId);
246
+ const value = clean(v.value);
247
+ if (!url || !value) continue;
248
+ // Its name: the key the server resolves to (by id), else that server's default, else the host.
249
+ const k = list.find((x) => x?.id && x.id === v.keyId) || list.find((x) => x?.backendId === v.backendId && x.default) ||
250
+ list.find((x) => x?.backendId === v.backendId);
251
+ const nm = clean(k?.name) || new URL(url).hostname;
252
+ if (out.some((o) => o.backendUrl === url)) continue; // one key per server
253
+ out.push({ name: nm, backendUrl: url, default: true, key: value });
254
+ }
255
+ const coder = {
256
+ url: clean(settings.agentCodingModelUrl),
257
+ model: clean(settings.agentCodingModelName),
258
+ kind: clean(settings.agentCodingModelKind),
259
+ };
260
+ return {
261
+ gu: "preset",
262
+ version: FULL_PRESET_VERSION,
263
+ note: "The whole setup: every coding server and its models, the default coder, the agent and the embedder — and each server's API key. This file CONTAINS live API keys.",
264
+ name: clean(name),
265
+ savedAt: t.savedAt,
266
+ ...(t.savedOn ? { savedOn: t.savedOn } : {}),
267
+ codingDefault: coder,
268
+ codingBackends: t.codingBackends,
269
+ agentModel: t.agentModel,
270
+ embedModel: t.embedModel,
271
+ keys: out,
272
+ };
273
+ }
274
+
275
+ /** How many servers a preset may carry — far above anyone's real list, low enough to refuse junk. */
276
+ export const MAX_PRESET_SERVERS = 20;
277
+
278
+ /**
279
+ * Is this text a usable API key? A preset someone typed or pasted can carry the MASKED form copied
280
+ * from the view ("sk-…abcd"), a placeholder, or a line break from a wrapped paste — each would
281
+ * replace a working key with one that fails every turn, which is exactly how a workspace breaks.
282
+ * → "" when fine, else the reason.
283
+ */
284
+ export function keyProblem(value) {
285
+ const v = String(value ?? "");
286
+ if (!v.trim()) return "it is empty";
287
+ if (/[…]|\.\.\./.test(v)) return "it is the MASKED form (with …) — copy the preset with c, not from the view";
288
+ if (/^<.*>$|your[-_ ]?(api[-_ ]?)?key|^x+$|^\*+$|placeholder|changeme/i.test(v.trim())) return "it is a placeholder, not a key";
289
+ if (/[\s\u0000-\u001f\u007f]/.test(v.trim()) || v !== v.trim()) return "it contains spaces or line breaks";
290
+ if (v.trim().length < 8) return "it is too short to be a key";
291
+ if (v.trim().length > 400) return "it is too long to be a key";
292
+ return "";
293
+ }
294
+
295
+ const sameServer = (a, b) => Boolean(clean(a) && clean(b)) && ensureV1Root(clean(a)).toLowerCase() === ensureV1Root(clean(b)).toLowerCase();
296
+
297
+ /**
298
+ * Check a version-2 preset with /load-settings' own rules — and then the ones a WHOLE-SETUP preset
299
+ * needs so that loading it cannot leave the workspace in a mess (#320, the user: "when I load a preset
300
+ * from JSON I typed, it must not break my workspace — wrong API key or anything"). Everything is
301
+ * checked BEFORE anything is applied; the first real problem refuses the whole preset, named.
302
+ * Things that are allowed but worth knowing come back as `warnings` for the plan.
303
+ */
304
+ export function validateFullPreset(doc) {
305
+ const back = validateTransfer(JSON.stringify({ ...doc, gu: "settings", version: SETTINGS_FILE_VERSION }));
306
+ if (!back.ok) return back;
307
+ const v = back.value;
308
+ const warnings = [];
309
+ if (!v.backends.length) return { ok: false, problem: "the preset has no coding servers — nothing to load." };
310
+ if (v.backends.length > MAX_PRESET_SERVERS) {
311
+ return { ok: false, problem: `the preset has ${v.backends.length} servers — at most ${MAX_PRESET_SERVERS}.` };
312
+ }
313
+ // SERVERS: each one real, each one once — "https://ollama.com" and "…/v1" are the SAME server,
314
+ // and two rows for it would split its keys between twins.
315
+ for (const [i, b] of v.backends.entries()) {
316
+ const at = `codingBackends[${i}]`;
317
+ if (!b.url) return { ok: false, problem: `${at} has no url.` };
318
+ if (!b.defaultModel) return { ok: false, problem: `${at} (${b.url}) has no defaultModel.` };
319
+ const twin = v.backends.findIndex((o, j) => j < i && sameServer(o.url, b.url));
320
+ if (twin >= 0) return { ok: false, problem: `${at} (${b.url}) is the same server as codingBackends[${twin}] (${v.backends[twin].url}).` };
321
+ }
322
+ // KEYS: each bound to a server IN THE PRESET, each a usable key, one default per server.
323
+ const defaults = new Map();
324
+ for (const [i, k] of v.keys.entries()) {
325
+ const at = `keys[${i}] "${k.name}"`;
326
+ if (!k.backendUrl) return { ok: false, problem: `${at} names no server (backendUrl) — a key belongs to a server.` };
327
+ const b = v.backends.find((x) => sameServer(x.url, k.backendUrl));
328
+ if (!b) return { ok: false, problem: `${at} is for ${k.backendUrl}, which is not one of the preset's servers.` };
329
+ if (k.key !== undefined) {
330
+ const why = keyProblem(k.key);
331
+ if (why) return { ok: false, problem: `${at} cannot be used: ${why}.` };
332
+ }
333
+ if (k.default) {
334
+ const root = ensureV1Root(b.url).toLowerCase();
335
+ if (defaults.has(root)) return { ok: false, problem: `${b.url} has two default keys ("${defaults.get(root)}" and "${k.name}") — mark one.` };
336
+ defaults.set(root, k.name);
337
+ }
338
+ }
339
+ const noKey = v.backends.filter((b) => !v.keys.some((k) => k.key && sameServer(k.backendUrl, b.url)));
340
+ for (const b of noKey) warnings.push(`${b.url} has no key in the preset — it keeps whatever key this machine has for it (or none).`);
341
+ // THE DEFAULT CODER must be a server and model the preset itself sets up.
342
+ const cd = doc.codingDefault && typeof doc.codingDefault === "object" ? doc.codingDefault : {};
343
+ if (cd.url !== undefined && cd.url !== "" && !isHttpUrl(cd.url)) {
344
+ return { ok: false, problem: `codingDefault.url must be an http(s) URL — got ${JSON.stringify(cd.url)}.` };
345
+ }
346
+ if (clean(cd.url) || clean(cd.model)) {
347
+ const b = v.backends.find((x) => sameServer(x.url, cd.url));
348
+ if (!b) return { ok: false, problem: `codingDefault is on ${clean(cd.url) || "no server"}, which is not one of the preset's servers.` };
349
+ const models = [b.defaultModel, ...b.models];
350
+ if (!models.includes(clean(cd.model))) {
351
+ return { ok: false, problem: `codingDefault model "${clean(cd.model)}" is not a model of ${b.url} (${models.join(", ")}).` };
352
+ }
353
+ }
354
+ // Agent / embedder on a server the preset does not set up: allowed (a local Ollama needs no row),
355
+ // but said — its key then has to come from this machine.
356
+ for (const [role, m] of [["agent model", v.agentModel], ["embedding model", v.embedModel]]) {
357
+ if (m.url && !v.backends.some((b) => sameServer(b.url, m.url))) warnings.push(`the ${role} is on ${m.url}, which the preset does not set up — its key must already be on this machine.`);
358
+ }
359
+ return {
360
+ ok: true,
361
+ full: true,
362
+ name: clean(doc.name),
363
+ value: v,
364
+ warnings,
365
+ carriesSecrets: back.carriesSecrets,
366
+ codingDefault: { url: clean(cd.url), model: clean(cd.model), kind: clean(cd.kind) },
367
+ savedAt: clean(doc.savedAt),
368
+ savedOn: clean(doc.savedOn),
369
+ };
370
+ }
371
+
372
+ /**
373
+ * WHICH KEYS TO ADD when a preset is applied (#320) — pure, so it is tested rather than trusted.
374
+ * A key the preset CARRIES is added, bound to its server's row; one this machine already has by the
375
+ * same name on the same server is KEPT (not duplicated, not overwritten); keys without a value come
376
+ * from `missing` (a settings file's, asked for). → { add: [{ name, key?, rowId, default }], kept: [...] }
377
+ */
378
+ export function keysToApply(value, { currentKeys = [], rows = [], missing = [] } = {}) {
379
+ const rowOf = (url) => providerRowForUrl(rows, url)?.id ?? "";
380
+ const existing = (k) => {
381
+ const row = rowOf(k.backendUrl);
382
+ return row ? currentKeys.find((c) => clean(c.name).toLowerCase() === clean(k.name).toLowerCase() && c.backendId === row) : null;
383
+ };
384
+ const have = (k) => Boolean(existing(k));
385
+ const carried = (value?.keys ?? []).filter((k) => k.key);
386
+ // KEPT keys still carry the preset's DEFAULT choice: restoring a backup after a preset made another
387
+ // key its server's default must make this one the default again, or the undo is incomplete.
388
+ const kept = carried.filter(have).map((k) => ({ ...k, rowId: rowOf(k.backendUrl), existingId: existing(k).id, default: k.default === true }));
389
+ const add = [...carried.filter((k) => !have(k)), ...missing].map((k) => ({
390
+ name: k.name,
391
+ ...(k.key ? { key: k.key } : {}),
392
+ backendUrl: k.backendUrl ?? "",
393
+ rowId: k.backendUrl ? rowOf(k.backendUrl) : "",
394
+ default: k.default === true,
395
+ ...(k.tail ? { tail: k.tail } : {}),
396
+ }));
397
+ return { add, kept };
398
+ }
399
+
400
+ /** A key as it may be SHOWN: its first few and last four characters. */
401
+ const mask = (k) => {
402
+ const v = clean(k);
403
+ if (!v) return "";
404
+ return v.length <= 8 ? "…" + v.slice(-2) : `${v.startsWith("sk-") ? "sk-" : v.slice(0, 3)}…${v.slice(-4)}`;
405
+ };
406
+
407
+ /** The whole preset with every key MASKED — what is printed; the file / the copy keep the real keys. */
408
+ export function maskedFullPreset(doc) {
409
+ return { ...doc, keys: (Array.isArray(doc?.keys) ? doc.keys : []).map((k) => ({ ...k, ...(k.key ? { key: mask(k.key) } : {}) })) };
410
+ }
411
+
412
+ /** The lines /load-presets prints before applying a version-2 preset (keys masked). */
413
+ export function fullPresetSummary(v, codingDefault) {
414
+ const lines = [];
415
+ for (const b of v.backends) {
416
+ const models = [...new Set([b.defaultModel, ...(b.models ?? [])].filter(Boolean))];
417
+ const key = v.keys.find((k) => k.backendUrl && ensureV1Root(k.backendUrl).toLowerCase() === ensureV1Root(b.url).toLowerCase());
418
+ lines.push(` ${b.url} ${models.join(", ") || "(no models)"}${key?.key ? ` · key ${key.name} ${mask(key.key)}` : " · no key"}`);
419
+ }
420
+ if (codingDefault?.url || codingDefault?.model) lines.push(`coding model: ${codingDefault.model || "?"} at ${codingDefault.url || "?"}`);
421
+ return lines;
422
+ }
@@ -2814,3 +2814,41 @@ export function hintRows(lines, { width, gutter = " ", paint = (s) => s } = {}
2814
2814
  }
2815
2815
  return out.map((r) => gutter + paint(r));
2816
2816
  }
2817
+
2818
+ /**
2819
+ * THE NOTICE ROW — its own row, directly ABOVE the separator (#318).
2820
+ *
2821
+ * The rule above ">>" is only a separator (the user, angrily: "the line is for separating the
2822
+ * user's input, not for that"). Four things used to ride on it, one at a time — the jump-to-bottom
2823
+ * hint, the update control, the gu token notice, the agents count — because the row looked free.
2824
+ * Now they share this row instead, right-aligned, joined with " · ", in PRIORITY order (the first
2825
+ * item is the most important). What does not fit is dropped WHOLE, lowest priority first — never
2826
+ * cut mid-word. No items → null, and the zone simply has no such row.
2827
+ *
2828
+ * items: [{ key, text, paint?, clickable? }] → { row, spans: { [key]: { from, to } } } | null
2829
+ * `spans` are 0-based columns of the CLICKABLE items, gutter included — what a click is tested on.
2830
+ */
2831
+ export function noticeRow(items, { width = 80, gutter = " ", sep = " · " } = {}) {
2832
+ const w = Math.max(20, width | 0);
2833
+ const room = w - 1 - visibleLen(gutter);
2834
+ const list = (Array.isArray(items) ? items : []).filter((i) => i && i.text);
2835
+ let kept = list;
2836
+ const joinedLen = (xs) => xs.reduce((n, x, i) => n + visibleLen(x.text) + (i ? sep.length : 0), 0);
2837
+ while (kept.length && joinedLen(kept) > room) kept = kept.slice(0, -1);
2838
+ if (!kept.length) return null;
2839
+ const start = w - 1 - joinedLen(kept);
2840
+ let row = " ".repeat(start);
2841
+ let col = start;
2842
+ const spans = {};
2843
+ kept.forEach((x, i) => {
2844
+ if (i) {
2845
+ row += sep;
2846
+ col += sep.length;
2847
+ }
2848
+ const len = visibleLen(x.text);
2849
+ if (x.clickable) spans[x.key] = { from: col, to: col + len };
2850
+ row += (x.paint ?? ((s) => s))(x.text);
2851
+ col += len;
2852
+ });
2853
+ return { row, spans };
2854
+ }