@tiens.nguyen/gu-cli 1.0.778 → 1.0.780

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/gu-repl.mjs CHANGED
@@ -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,
@@ -1579,12 +1580,11 @@ let imageNoticeTimer = null;
1579
1580
  let agentMissing = false; // no agent model chosen → agent falls back to the coding model
1580
1581
  let embedMissing = false; // no embed model chosen → RAG is off until /embed-model picks one
1581
1582
  // One footer row; the two halves are independent so a model that IS set is never named.
1582
- const missingNoticeText = () => {
1583
- const parts = [];
1584
- if (agentMissing) parts.push("agent model is not set — using the coding model (/agent-model to choose one)");
1585
- if (embedMissing) parts.push("no embed model — RAG is off; choose one with /embed-model");
1586
- return parts.length ? "⚠ " + parts.join(" · ") : "";
1587
- };
1583
+ // ONE SHORT ROW EACH (#317): joined, they ran past 80 columns and were cut mid-word.
1584
+ const missingNoticeRows = () => [
1585
+ ...(agentMissing ? ["⚠ agent model not set — using the coding model · /agent-model"] : []),
1586
+ ...(embedMissing ? ["⚠ no embed model — RAG is off · /embed-model"] : []),
1587
+ ];
1588
1588
 
1589
1589
  // #307: the calm note after a quick change of mind — dim, not the ⚠ orange of a warning, and in the
1590
1590
  // bar rather than the transcript (which the whole point is to leave untouched).
@@ -2533,7 +2533,7 @@ const footerRows = () =>
2533
2533
  ? dim(clipVisible(stopNotice, (process.stdout.columns || 80) - visibleLen(GUTTER) - 1))
2534
2534
  : "",
2535
2535
  // Task #14: the skipped-setup warning — persistent (not a timed notice), red, its own row.
2536
- missing: missingNoticeText() ? red(missingNoticeText()) : "",
2536
+ missing: missingNoticeRows().map((r) => red(r)),
2537
2537
  });
2538
2538
  // An ask()'s hint lines (#305), drawn in yellow under its input while it waits. Plain text.
2539
2539
  let askHint = [];
@@ -2605,6 +2605,28 @@ function syncBarPinning() {
2605
2605
  if (!canPinBar()) unpinBar();
2606
2606
  else pinBar();
2607
2607
  }
2608
+ /**
2609
+ * THE NOTICE ROW ABOVE THE SEPARATOR (#318) — or null when there is nothing to say.
2610
+ * Priority, highest first: where you are (jump to bottom), what you can act on with a click (an
2611
+ * update), a standing fact about the licence (the gu token notice — yellow only when urgent), and
2612
+ * other agents. Dropped whole, lowest first, when they do not all fit.
2613
+ */
2614
+ function zoneNotice(width) {
2615
+ const items = [];
2616
+ if (scrolledBack()) {
2617
+ const n = scrollOffset;
2618
+ items.push({ key: "jump", clickable: true, paint: accent, text: `↓ jump to bottom (click) · ${n} line${n === 1 ? "" : "s"} below` });
2619
+ }
2620
+ if (availableUpdates.any) {
2621
+ const up = updateRow({ ...availableUpdates, width: 400 });
2622
+ if (up) items.push({ key: "update", clickable: true, paint: accent, text: up.text.slice(up.from) });
2623
+ }
2624
+ if (tokenNotice?.text) items.push({ key: "token", paint: tokenNotice.urgent ? yellow : dim, text: tokenNotice.text });
2625
+ const agents = agentsIndicator(otherAgents);
2626
+ if (agents) items.push({ key: "agents", clickable: true, paint: accent, text: `${agents} running (click)` });
2627
+ return noticeRow(items, { width, gutter: GUTTER });
2628
+ }
2629
+
2608
2630
  /**
2609
2631
  * The whole frozen zone, top to bottom: a separator, a blank row, the INPUT LINE, then the
2610
2632
  * bar's rule, token and mode rows. The blank is what lifts the separator — and the control it
@@ -2625,17 +2647,12 @@ function frozenZone() {
2625
2647
  // #307: a question held until its turn prints anything sits on top of the status block — where
2626
2648
  // it will land once committed, so nothing moves when it does.
2627
2649
  const status = footerActive ? [] : [...pendingEchoRows(), ...liveStatusRows];
2628
- // DECIDED BEFORE THE LAYOUT, because the head's height depends on it: the blank under the
2629
- // separator exists to lift a CONTROL off the typed line, so with no control there is nothing
2630
- // to lift. The same three conditions the rows[sep] writes below test — kept adjacent so they
2631
- // cannot drift into disagreeing about whether a control is showing.
2632
- // The token notice counts only when it FITS — the same test its write below uses. With
2633
- // Boolean(tokenNotice) alone, a narrow terminal lifted a blank row for a line it never drew.
2634
- const tokenFits = Boolean(tokenNotice) && visibleLen(tokenNotice.text) + 4 <= width - 1;
2635
- const headControl =
2636
- scrolledBack() || availableUpdates.any || tokenFits || Boolean(agentsIndicator(otherAgents));
2650
+ // THE SEPARATOR CARRIES NOTHING (#318). What used to ride on it — jump-to-bottom, the update
2651
+ // control, the gu token notice, the agents count — is on its own NOTICE ROW above it; the head
2652
+ // is just the rule, with no blank to lift a control off the typed line.
2653
+ const notice = zoneNotice(width);
2637
2654
  const zone = inputZoneRows({
2638
- headControl,
2655
+ headControl: false,
2639
2656
  width,
2640
2657
  // #258: typing during a turn is not sent — the row says so (an ask() label still comes first).
2641
2658
  prompt: secretInput && secretPrompt
@@ -2680,61 +2697,14 @@ function frozenZone() {
2680
2697
  selection: secretInput ? null : inputSelection,
2681
2698
  highlight: highlightRow,
2682
2699
  });
2683
- const rows = status.concat(zone.rows, askHintRows(width), footerRows());
2684
- // THE SEPARATOR IS NO LONGER ROW 0. With the status block prepended it sits `status.length`
2685
- // rows down, and every affordance below writes to it by index — a bare rows[0] would paint
2686
- // the jump hint over the thought line and hit-test the wrong row.
2687
- const sep = status.length;
2688
- // THE SEPARATOR ROW carries at most one affordance, and the two contend for it:
2689
- //
2690
- // · scrolled back → "↓ jump to bottom", which WINS. It is transient and about where you are
2691
- // right now; an update is neither, and will still be there when you come back.
2692
- // · otherwise, and only when something is genuinely newer → the update control (task #181).
2693
- //
2694
- // Both are stored with their COLUMN span, because the row is hit-tested by column now — one
2695
- // row, two possible targets, and "anywhere on this row" would make the wrong one fire.
2696
- updateRowTarget = null;
2697
- agentsRowTarget = null;
2698
- if (scrolledBack()) {
2699
- const hint = jumpToBottomRow({ below: scrollOffset, width });
2700
- rows[sep] = clipVisible(accent(hint.text), width - 1);
2701
- } else if (availableUpdates.any) {
2702
- const up = updateRow({ ...availableUpdates, width });
2703
- if (up) {
2704
- rows[sep] = clipVisible(dim(up.text.slice(0, up.from)) + accent(up.text.slice(up.from)), width - 1);
2705
- updateRowTarget = { from: up.from, to: up.to };
2706
- }
2707
- } else if (tokenFits) {
2708
- // The gu token's one line (#232), right-aligned like the controls around it. BELOW the update
2709
- // control, because an update is actionable with a click and this is a standing fact; ABOVE the
2710
- // agents count, because it needs the user and that does not. Not clickable. Too narrow to say
2711
- // it whole → the condition above lets the agents count (or nothing) have the row instead.
2712
- const len = visibleLen(tokenNotice.text);
2713
- const from = width - 1 - len;
2714
- // Yellow only when the user must act (plan ended, over the computer limit); an old token is
2715
- // routine and reads in the rule's own dim grey (#292 — the yellow line "seems so serious").
2716
- const paint = tokenNotice.urgent ? yellow : dim;
2717
- rows[sep] = clipVisible(dim("\u2500".repeat(Math.max(0, from - 1)) + " ") + paint(tokenNotice.text), width - 1);
2718
- } else if (agentsIndicator(otherAgents)) {
2719
- // …and when there is no update to offer, the same row carries the agents count (task #195).
2720
- // THREE THINGS CANNOT SHARE ONE ROW, so they are ranked by how time-critical they are:
2721
- // the jump hint is about where you are RIGHT NOW, an update is a standing fact, and other
2722
- // agents change on their own. Update wins over agents because it is rarer and actionable;
2723
- // agents are always one click away through the same row once it is free.
2724
- const label = `${agentsIndicator(otherAgents)} running (click)`;
2725
- const len = visibleLen(label);
2726
- if (len + 4 <= width - 1) {
2727
- const from = width - 1 - len;
2728
- rows[sep] = clipVisible(dim("\u2500".repeat(Math.max(0, from - 1)) + " ") + accent(label), width - 1);
2729
- agentsRowTarget = { from, to: from + len };
2730
- }
2731
- }
2732
- // Spread, not a hand-written pick: this used to list the fields it forwarded, and adding
2733
- // rowCount/caretRow to inputZoneRows left them silently undefined here — the caret would have
2734
- // parked on the first row of a wrapped input forever.
2735
- // statusRows so paintBar can offset the input row and the separator's absolute position;
2736
- // without it every click and caret park would be `status.length` rows out.
2737
- return { ...zone, rows, statusRows: sep };
2700
+ const rows = status.concat(notice ? [notice.row] : [], zone.rows, askHintRows(width), footerRows());
2701
+ // The separator is `status.length (+1 for the notice row)` rows down — everything that writes to
2702
+ // or hit-tests a row by index counts from here.
2703
+ const sep = status.length + (notice ? 1 : 0);
2704
+ updateRowTarget = notice?.spans.update ?? null;
2705
+ agentsRowTarget = notice?.spans.agents ?? null;
2706
+ jumpSpan = notice?.spans.jump ?? null;
2707
+ return { ...zone, rows, statusRows: sep, noticeRow: Boolean(notice) };
2738
2708
  }
2739
2709
  /**
2740
2710
  * Put the output position back at column 0 of its own row — without printing anything.
@@ -2774,8 +2744,10 @@ function paintBar() {
2774
2744
  // The rows exactly as painted, so a double-click reads the same characters the user is
2775
2745
  // looking at rather than re-deriving them from rl.line at a width this may not be.
2776
2746
  inputRowTexts = zone.rows.slice((zone.statusRows ?? 0) + head);
2777
- jumpRowTarget = scrolledBack() ? { row: sepAbs } : null; // the SEPARATOR row
2778
- updateRowAbs = updateRowTarget || agentsRowTarget ? sepAbs : 0; // …the same row, when showing
2747
+ // THE NOTICE ROW is the one directly above the separator (#318) — never the separator itself.
2748
+ const noticeAbs = zone.noticeRow ? sepAbs - 1 : 0;
2749
+ jumpRowTarget = scrolledBack() && jumpSpan ? { row: noticeAbs, ...jumpSpan } : null;
2750
+ updateRowAbs = updateRowTarget || agentsRowTarget ? noticeAbs : 0;
2779
2751
  // preserveCaret:false — the save slot holds the OUTPUT position (see regionUi), and the caret
2780
2752
  // is parked on the input line right after, so there is nothing to preserve here.
2781
2753
  ui(
@@ -2929,6 +2901,8 @@ let liveStatusRows = [];
2929
2901
  // Where the "jump to bottom" line currently is (1-based viewport row + column span), so a click
2930
2902
  // can hit it. Null while the view is live.
2931
2903
  let jumpRowTarget = null;
2904
+ /** The jump-to-bottom hint's column span on the notice row (#318), set by frozenZone. */
2905
+ let jumpSpan = null;
2932
2906
  // The update control's column span on the separator row, and that row's absolute position.
2933
2907
  // Column-scoped (task #181): the row is shared with the jump-to-bottom hint, so a click has to
2934
2908
  // land ON the label rather than anywhere along it.
@@ -7060,6 +7034,83 @@ async function runUndoCommand(line) {
7060
7034
  if (runId === lastStoppedRun) lastStoppedRun = null;
7061
7035
  }
7062
7036
 
7037
+ /**
7038
+ * #317: a preset the person ALREADY HAS, brought in by pasting — its JSON, a whole preset file, or
7039
+ * the `/save-preset <name> {…}` command copied from another computer (#312). A multi-line paste
7040
+ * arrives as "[Pasted text #N +M lines]" and is expanded before it is read, so the key is never
7041
+ * echoed. A mistake is named; Enter then opens it IN THE EDITOR with the pasted text (not a blank
7042
+ * template), and Enter on nothing opens the editor on the current setup. Esc cancels.
7043
+ * Saved 0600 like /save-preset. → the file, or "" when cancelled.
7044
+ */
7045
+ async function presetFromPaste(pr, home) {
7046
+ const ed = await import("./preset-editor.mjs");
7047
+ console.log(GUTTER + "Paste a preset you already have — its JSON, a preset file, or a /save-preset command.");
7048
+ console.log(GUTTER + dim("Enter on an empty line opens your editor instead · Esc cancels."));
7049
+ let parsed = null;
7050
+ let lastText = "";
7051
+ try {
7052
+ while (!parsed) {
7053
+ const text = String((await ask("preset: ")) ?? "");
7054
+ if (!text.trim()) {
7055
+ const seed = lastText || ed.editorText("preset", ed.presetFromSettings((await fetchWorkerSettings()) ?? {}));
7056
+ parsed = await fixPresetInEditor(ed, seed);
7057
+ if (!parsed) return "";
7058
+ break;
7059
+ }
7060
+ const p = ed.parsePastedPreset(text);
7061
+ if (p.ok) {
7062
+ parsed = p;
7063
+ break;
7064
+ }
7065
+ lastText = text;
7066
+ console.log(GUTTER + yellow(`Not a preset yet — ${p.problem}`));
7067
+ console.log(GUTTER + dim("Paste it again, or press Enter to fix it in your editor."));
7068
+ }
7069
+ const name = await askPresetName(pr, parsed.name || "preset");
7070
+ if (!name) return "";
7071
+ const file = pr.presetFilePath(name, home);
7072
+ if (existsSync(file) && !(await askYesNo(`A preset called "${name}" exists — replace it?`, false))) return "";
7073
+ writeFileSync(file, JSON.stringify(pr.buildPreset({ name, preset: parsed.preset, host: hostname() }), null, 2) + "\n", { mode: 0o600 });
7074
+ console.log(GUTTER + `Preset "${name}" saved to ${file}`);
7075
+ return file;
7076
+ } catch (e) {
7077
+ if (wasPromptCancelled(e)) return "";
7078
+ throw e;
7079
+ }
7080
+ }
7081
+
7082
+ /** The editor loop of #312, seeded with `text`: → a parsed preset, or null when given up. */
7083
+ async function fixPresetInEditor(ed, text) {
7084
+ const editor = ed.pickEditor();
7085
+ if (!editor || !process.stdin.isTTY) {
7086
+ console.log(GUTTER + (editor ? "an editor needs a terminal." : "no editor found (set $EDITOR, e.g. export EDITOR=nano)."));
7087
+ return null;
7088
+ }
7089
+ let t = text;
7090
+ for (;;) {
7091
+ const r = withTerminalHandedOver(() => ed.editText(t, { editor, fileName: "preset.json" }));
7092
+ if (!r.ok) {
7093
+ console.log(GUTTER + `Not saved — ${r.why}.`);
7094
+ return null;
7095
+ }
7096
+ const p = ed.parsePastedPreset(r.text);
7097
+ if (p.ok) return p;
7098
+ console.log(GUTTER + yellow(`Not saved yet — ${p.problem}`));
7099
+ if (!(await askYesNo("Edit again?", true))) return null;
7100
+ t = r.text;
7101
+ }
7102
+ }
7103
+
7104
+ /** A preset name, defaulting to `dflt` (Enter). "" when cancelled. */
7105
+ async function askPresetName(pr, dflt) {
7106
+ for (;;) {
7107
+ const n = String((await ask(`preset name${dflt ? ` [${dflt}]` : ""}: `)) ?? "").trim() || dflt;
7108
+ if (!n) return "";
7109
+ if (pr.isPresetName(n)) return n;
7110
+ console.log(GUTTER + yellow(`"${n}" is not a preset name — one word, no slashes.`));
7111
+ }
7112
+ }
7113
+
7063
7114
  async function runLoadPresetsCommand(line) {
7064
7115
  const pr = await import("./presets-transfer.mjs");
7065
7116
  const home = homedir();
@@ -7082,53 +7133,72 @@ async function runLoadPresetsCommand(line) {
7082
7133
  // picker that stayed open could not be clicked until it closed.
7083
7134
  const ed = await import("./preset-editor.mjs");
7084
7135
  const found = pr.presetFilesIn(listJsonFiles(home));
7136
+ // NO DEAD END (#317: "I need a place to input a preset I have already"): with nothing saved,
7137
+ // the command asks for one right here; with presets saved, the picker's LAST row does (last, so
7138
+ // Enter still applies the newest preset, as it always has).
7085
7139
  if (found.length === 0) {
7086
- console.log(GUTTER + `no presets in ${home} — save one with /save-preset <name>`);
7087
- console.log("");
7088
- return;
7089
- }
7090
- console.log(dim(` Presets in ${home} (${ed.PICKER_HINT}):`));
7091
- const pick = await pickFromList(found, 0, { keys: ed.PICKER_KEYS });
7092
- if (!pick || pick.index < 0) {
7093
- console.log(GUTTER + "load cancelled.");
7094
- console.log("");
7095
- return;
7096
- }
7097
- file = pr.presetFilePath(found[pick.index], home);
7098
- if (pick.action === "edit") {
7099
- await runSavePresetCommand(`/save-preset ${found[pick.index]}`);
7100
- return;
7101
- }
7102
- if (pick.action !== "select") {
7103
- const shown = pr.validatePresetFile(readFileSync(file, "utf8"));
7104
- if (!shown.ok) {
7105
- console.log(GUTTER + `Can't show it — ${shown.problem}`);
7140
+ console.log(GUTTER + "No presets saved yet.");
7141
+ file = await presetFromPaste(pr, home);
7142
+ if (!file) {
7143
+ console.log(GUTTER + "load cancelled.");
7106
7144
  console.log("");
7107
7145
  return;
7108
7146
  }
7109
- const pname = shown.name || found[pick.index];
7110
- const command = ed.presetCopyCommand(pname, shown.preset);
7111
- if (pick.action === "view") {
7112
- const block = ed.presetBlock(pname, shown.preset, { width: process.stdout.columns || 80, gutter: GUTTER });
7113
- const [top, ...restRows] = block.rows;
7114
- const cut = block.button.from;
7115
- console.log(dim(top.slice(0, cut)) + accent(top.slice(cut, block.button.to)) + dim(top.slice(block.button.to)));
7116
- for (const r of restRows) console.log(r);
7117
- registerCopyBlock(block.rows.length, command, 0, { button: block.button, note: "✓ copied — includes the API key" });
7118
- console.log(GUTTER + dim("click ⧉ copy for a /save-preset command to paste anywhere — it includes the real key."));
7119
- console.log(GUTTER + dim(`/load-presets ${pname} applies it.`));
7147
+ } else {
7148
+ console.log(dim(` Presets in ${home} (${ed.PICKER_HINT}):`));
7149
+ const PASTE_ROW = "+ Paste a preset I already have…";
7150
+ const pick = await pickFromList([...found, PASTE_ROW], 0, { keys: ed.PICKER_KEYS });
7151
+ if (!pick || pick.index < 0) {
7152
+ console.log(GUTTER + "load cancelled.");
7153
+ console.log("");
7154
+ return;
7155
+ }
7156
+ if (pick.index === found.length) {
7157
+ file = await presetFromPaste(pr, home);
7158
+ if (!file) {
7159
+ console.log(GUTTER + "load cancelled.");
7160
+ console.log("");
7161
+ return;
7162
+ }
7163
+ pick.action = "select"; // a pasted preset goes on to be applied, whatever key opened the row
7164
+ } else {
7165
+ file = pr.presetFilePath(found[pick.index], home);
7166
+ }
7167
+ if (pick.action === "edit") {
7168
+ await runSavePresetCommand(`/save-preset ${found[pick.index]}`);
7169
+ return;
7170
+ }
7171
+ if (pick.action !== "select") {
7172
+ const shown = pr.validatePresetFile(readFileSync(file, "utf8"));
7173
+ if (!shown.ok) {
7174
+ console.log(GUTTER + `Can't show it — ${shown.problem}`);
7175
+ console.log("");
7176
+ return;
7177
+ }
7178
+ const pname = shown.name || found[pick.index];
7179
+ const command = ed.presetCopyCommand(pname, shown.preset);
7180
+ if (pick.action === "view") {
7181
+ const block = ed.presetBlock(pname, shown.preset, { width: process.stdout.columns || 80, gutter: GUTTER });
7182
+ const [top, ...restRows] = block.rows;
7183
+ const cut = block.button.from;
7184
+ console.log(dim(top.slice(0, cut)) + accent(top.slice(cut, block.button.to)) + dim(top.slice(block.button.to)));
7185
+ for (const r of restRows) console.log(r);
7186
+ registerCopyBlock(block.rows.length, command, 0, { button: block.button, note: "✓ copied — includes the API key" });
7187
+ console.log(GUTTER + dim("click ⧉ copy for a /save-preset command to paste anywhere — it includes the real key."));
7188
+ console.log(GUTTER + dim(`/load-presets ${pname} applies it.`));
7189
+ console.log("");
7190
+ return;
7191
+ }
7192
+ const res = await writeClipboard(pick.action === "json" ? ed.presetCopyJson(shown.preset) : command);
7193
+ console.log(
7194
+ GUTTER +
7195
+ (res.ok
7196
+ ? accent(`✓ copied ${pick.action === "json" ? "the JSON" : "the /save-preset command"} for "${pname}" — includes the API key.`)
7197
+ : yellow(`couldn't copy (${res.why}) — the preset is in ${file}.`))
7198
+ );
7120
7199
  console.log("");
7121
7200
  return;
7122
7201
  }
7123
- const res = await writeClipboard(pick.action === "json" ? ed.presetCopyJson(shown.preset) : command);
7124
- console.log(
7125
- GUTTER +
7126
- (res.ok
7127
- ? accent(`✓ copied ${pick.action === "json" ? "the JSON" : "the /save-preset command"} for "${pname}" — includes the API key.`)
7128
- : yellow(`couldn't copy (${res.why}) — the preset is in ${file}.`))
7129
- );
7130
- console.log("");
7131
- return;
7132
7202
  }
7133
7203
  }
7134
7204
  const checked = pr.validatePresetFile(readFileSync(file, "utf8"));
@@ -98,7 +98,7 @@ export function modelSetupMenu({ presets = 0 } = {}) {
98
98
  " 1 Guided — pick the servers and models (Enter)",
99
99
  presets > 0
100
100
  ? ` 2 Load a saved preset (${presets} saved)`
101
- : " 2 Load a saved preset (none saved yet — /save-preset makes one)",
101
+ : " 2 Load a preset (none saved yet — paste one you already have)",
102
102
  " 3 Skip for now — set them later with /model",
103
103
  ];
104
104
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.778",
3
+ "version": "1.0.780",
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/preset-editor.mjs CHANGED
@@ -185,3 +185,38 @@ export function presetBlock(name, preset, { width = 80, gutter = " " } = {}) {
185
185
  /** The keys the /load-presets picker takes, in the order its hint line names them. */
186
186
  export const PICKER_KEYS = { v: "view", c: "copy", j: "json", e: "edit" };
187
187
  export const PICKER_HINT = "↑↓ · Enter apply · v view · c copy command · j copy JSON · e edit · Esc back";
188
+
189
+ /**
190
+ * A PRESET SOMEONE ALREADY HAS, pasted into /load-presets (#317: "I need a place to input a preset
191
+ * I have already, which is in JSON format"). Three shapes are accepted, because those are the three
192
+ * things a person could be holding:
193
+ *
194
+ * · the JSON itself { "apiUrl": …, "apiKey": …, "agentModel": …, … }
195
+ * · a whole preset FILE { "gu": "preset", "name": "home", "version": 1, … }
196
+ * · the command copied with c /save-preset home {"apiUrl":…} (#312, from another computer)
197
+ *
198
+ * → { ok: true, preset, name } (name = the one the text carries, or "") | { ok: false, problem }.
199
+ * JSON problems name the line and column (parseEdited), and `//` lines are allowed, as in the editor.
200
+ */
201
+ export function parsePastedPreset(text) {
202
+ let src = String(text ?? "").trim();
203
+ if (!src) return { ok: false, empty: true, problem: "nothing was pasted." };
204
+ let name = "";
205
+ const cmd = /^\/save-preset\s+(\S+)\s+([\s\S]+)$/.exec(src);
206
+ if (cmd) {
207
+ name = cmd[1];
208
+ src = cmd[2].trim();
209
+ } else if (/^\/save-preset\b/.test(src)) {
210
+ return { ok: false, problem: "that /save-preset command has no JSON after its name." };
211
+ }
212
+ const parsed = parseEdited(src);
213
+ if (!parsed.ok) return parsed;
214
+ // A whole preset file carries its name inside; the command's own name wins when both exist.
215
+ try {
216
+ const doc = JSON.parse(src.replace(/^[ \t]*\/\/.*$/gm, ""));
217
+ if (!name && doc && typeof doc.name === "string" && doc.gu === "preset") name = doc.name.trim();
218
+ } catch {
219
+ /* parseEdited already accepted it — a name is optional */
220
+ }
221
+ return { ok: true, preset: parsed.preset, name };
222
+ }
@@ -522,7 +522,9 @@ export function buildFooterRows({
522
522
  // Task #14: a skipped model setup stays VISIBLE. When private/client-expert setup was
523
523
  // declined the agent model and/or the embed model is unset — say so at the bottom, every
524
524
  // frame, until one is chosen. Its own row, under the passing notice, so it persists.
525
- ...(missing ? [clipVisible(gutter + missing, w - 1)] : []),
525
+ // One row EACH (#317): the two warnings joined on one row ran past 80 columns and were cut
526
+ // mid-word ("…(/agent-model to choose on"). A string still works — one row, as before.
527
+ ...(Array.isArray(missing) ? missing : missing ? [missing] : []).filter(Boolean).map((m) => clipVisible(gutter + m, w - 1)),
526
528
  ];
527
529
  }
528
530
 
@@ -2812,3 +2814,41 @@ export function hintRows(lines, { width, gutter = " ", paint = (s) => s } = {}
2812
2814
  }
2813
2815
  return out.map((r) => gutter + paint(r));
2814
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
+ }