@tiens.nguyen/gu-cli 1.0.778 → 1.0.779

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
@@ -1579,12 +1579,11 @@ let imageNoticeTimer = null;
1579
1579
  let agentMissing = false; // no agent model chosen → agent falls back to the coding model
1580
1580
  let embedMissing = false; // no embed model chosen → RAG is off until /embed-model picks one
1581
1581
  // 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
- };
1582
+ // ONE SHORT ROW EACH (#317): joined, they ran past 80 columns and were cut mid-word.
1583
+ const missingNoticeRows = () => [
1584
+ ...(agentMissing ? ["⚠ agent model not set — using the coding model · /agent-model"] : []),
1585
+ ...(embedMissing ? ["⚠ no embed model — RAG is off · /embed-model"] : []),
1586
+ ];
1588
1587
 
1589
1588
  // #307: the calm note after a quick change of mind — dim, not the ⚠ orange of a warning, and in the
1590
1589
  // bar rather than the transcript (which the whole point is to leave untouched).
@@ -2533,7 +2532,7 @@ const footerRows = () =>
2533
2532
  ? dim(clipVisible(stopNotice, (process.stdout.columns || 80) - visibleLen(GUTTER) - 1))
2534
2533
  : "",
2535
2534
  // Task #14: the skipped-setup warning — persistent (not a timed notice), red, its own row.
2536
- missing: missingNoticeText() ? red(missingNoticeText()) : "",
2535
+ missing: missingNoticeRows().map((r) => red(r)),
2537
2536
  });
2538
2537
  // An ask()'s hint lines (#305), drawn in yellow under its input while it waits. Plain text.
2539
2538
  let askHint = [];
@@ -7060,6 +7059,83 @@ async function runUndoCommand(line) {
7060
7059
  if (runId === lastStoppedRun) lastStoppedRun = null;
7061
7060
  }
7062
7061
 
7062
+ /**
7063
+ * #317: a preset the person ALREADY HAS, brought in by pasting — its JSON, a whole preset file, or
7064
+ * the `/save-preset <name> {…}` command copied from another computer (#312). A multi-line paste
7065
+ * arrives as "[Pasted text #N +M lines]" and is expanded before it is read, so the key is never
7066
+ * echoed. A mistake is named; Enter then opens it IN THE EDITOR with the pasted text (not a blank
7067
+ * template), and Enter on nothing opens the editor on the current setup. Esc cancels.
7068
+ * Saved 0600 like /save-preset. → the file, or "" when cancelled.
7069
+ */
7070
+ async function presetFromPaste(pr, home) {
7071
+ const ed = await import("./preset-editor.mjs");
7072
+ console.log(GUTTER + "Paste a preset you already have — its JSON, a preset file, or a /save-preset command.");
7073
+ console.log(GUTTER + dim("Enter on an empty line opens your editor instead · Esc cancels."));
7074
+ let parsed = null;
7075
+ let lastText = "";
7076
+ try {
7077
+ while (!parsed) {
7078
+ const text = String((await ask("preset: ")) ?? "");
7079
+ if (!text.trim()) {
7080
+ const seed = lastText || ed.editorText("preset", ed.presetFromSettings((await fetchWorkerSettings()) ?? {}));
7081
+ parsed = await fixPresetInEditor(ed, seed);
7082
+ if (!parsed) return "";
7083
+ break;
7084
+ }
7085
+ const p = ed.parsePastedPreset(text);
7086
+ if (p.ok) {
7087
+ parsed = p;
7088
+ break;
7089
+ }
7090
+ lastText = text;
7091
+ console.log(GUTTER + yellow(`Not a preset yet — ${p.problem}`));
7092
+ console.log(GUTTER + dim("Paste it again, or press Enter to fix it in your editor."));
7093
+ }
7094
+ const name = await askPresetName(pr, parsed.name || "preset");
7095
+ if (!name) return "";
7096
+ const file = pr.presetFilePath(name, home);
7097
+ 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 });
7099
+ console.log(GUTTER + `Preset "${name}" saved to ${file}`);
7100
+ return file;
7101
+ } catch (e) {
7102
+ if (wasPromptCancelled(e)) return "";
7103
+ throw e;
7104
+ }
7105
+ }
7106
+
7107
+ /** The editor loop of #312, seeded with `text`: → a parsed preset, or null when given up. */
7108
+ async function fixPresetInEditor(ed, text) {
7109
+ const editor = ed.pickEditor();
7110
+ if (!editor || !process.stdin.isTTY) {
7111
+ console.log(GUTTER + (editor ? "an editor needs a terminal." : "no editor found (set $EDITOR, e.g. export EDITOR=nano)."));
7112
+ return null;
7113
+ }
7114
+ let t = text;
7115
+ for (;;) {
7116
+ const r = withTerminalHandedOver(() => ed.editText(t, { editor, fileName: "preset.json" }));
7117
+ if (!r.ok) {
7118
+ console.log(GUTTER + `Not saved — ${r.why}.`);
7119
+ return null;
7120
+ }
7121
+ const p = ed.parsePastedPreset(r.text);
7122
+ if (p.ok) return p;
7123
+ console.log(GUTTER + yellow(`Not saved yet — ${p.problem}`));
7124
+ if (!(await askYesNo("Edit again?", true))) return null;
7125
+ t = r.text;
7126
+ }
7127
+ }
7128
+
7129
+ /** A preset name, defaulting to `dflt` (Enter). "" when cancelled. */
7130
+ async function askPresetName(pr, dflt) {
7131
+ for (;;) {
7132
+ const n = String((await ask(`preset name${dflt ? ` [${dflt}]` : ""}: `)) ?? "").trim() || dflt;
7133
+ if (!n) return "";
7134
+ if (pr.isPresetName(n)) return n;
7135
+ console.log(GUTTER + yellow(`"${n}" is not a preset name — one word, no slashes.`));
7136
+ }
7137
+ }
7138
+
7063
7139
  async function runLoadPresetsCommand(line) {
7064
7140
  const pr = await import("./presets-transfer.mjs");
7065
7141
  const home = homedir();
@@ -7082,53 +7158,72 @@ async function runLoadPresetsCommand(line) {
7082
7158
  // picker that stayed open could not be clicked until it closed.
7083
7159
  const ed = await import("./preset-editor.mjs");
7084
7160
  const found = pr.presetFilesIn(listJsonFiles(home));
7161
+ // NO DEAD END (#317: "I need a place to input a preset I have already"): with nothing saved,
7162
+ // the command asks for one right here; with presets saved, the picker's LAST row does (last, so
7163
+ // Enter still applies the newest preset, as it always has).
7085
7164
  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}`);
7165
+ console.log(GUTTER + "No presets saved yet.");
7166
+ file = await presetFromPaste(pr, home);
7167
+ if (!file) {
7168
+ console.log(GUTTER + "load cancelled.");
7106
7169
  console.log("");
7107
7170
  return;
7108
7171
  }
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.`));
7172
+ } else {
7173
+ console.log(dim(` Presets in ${home} (${ed.PICKER_HINT}):`));
7174
+ const PASTE_ROW = "+ Paste a preset I already have…";
7175
+ const pick = await pickFromList([...found, PASTE_ROW], 0, { keys: ed.PICKER_KEYS });
7176
+ if (!pick || pick.index < 0) {
7177
+ console.log(GUTTER + "load cancelled.");
7178
+ console.log("");
7179
+ return;
7180
+ }
7181
+ if (pick.index === found.length) {
7182
+ file = await presetFromPaste(pr, home);
7183
+ if (!file) {
7184
+ console.log(GUTTER + "load cancelled.");
7185
+ console.log("");
7186
+ return;
7187
+ }
7188
+ pick.action = "select"; // a pasted preset goes on to be applied, whatever key opened the row
7189
+ } else {
7190
+ file = pr.presetFilePath(found[pick.index], home);
7191
+ }
7192
+ if (pick.action === "edit") {
7193
+ await runSavePresetCommand(`/save-preset ${found[pick.index]}`);
7194
+ return;
7195
+ }
7196
+ if (pick.action !== "select") {
7197
+ const shown = pr.validatePresetFile(readFileSync(file, "utf8"));
7198
+ if (!shown.ok) {
7199
+ console.log(GUTTER + `Can't show it — ${shown.problem}`);
7200
+ console.log("");
7201
+ return;
7202
+ }
7203
+ const pname = shown.name || found[pick.index];
7204
+ const command = ed.presetCopyCommand(pname, shown.preset);
7205
+ if (pick.action === "view") {
7206
+ const block = ed.presetBlock(pname, shown.preset, { width: process.stdout.columns || 80, gutter: GUTTER });
7207
+ const [top, ...restRows] = block.rows;
7208
+ const cut = block.button.from;
7209
+ console.log(dim(top.slice(0, cut)) + accent(top.slice(cut, block.button.to)) + dim(top.slice(block.button.to)));
7210
+ for (const r of restRows) console.log(r);
7211
+ registerCopyBlock(block.rows.length, command, 0, { button: block.button, note: "✓ copied — includes the API key" });
7212
+ console.log(GUTTER + dim("click ⧉ copy for a /save-preset command to paste anywhere — it includes the real key."));
7213
+ console.log(GUTTER + dim(`/load-presets ${pname} applies it.`));
7214
+ console.log("");
7215
+ return;
7216
+ }
7217
+ const res = await writeClipboard(pick.action === "json" ? ed.presetCopyJson(shown.preset) : command);
7218
+ console.log(
7219
+ GUTTER +
7220
+ (res.ok
7221
+ ? accent(`✓ copied ${pick.action === "json" ? "the JSON" : "the /save-preset command"} for "${pname}" — includes the API key.`)
7222
+ : yellow(`couldn't copy (${res.why}) — the preset is in ${file}.`))
7223
+ );
7120
7224
  console.log("");
7121
7225
  return;
7122
7226
  }
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
7227
  }
7133
7228
  }
7134
7229
  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.779",
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