@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 +184 -114
- package/model-setup-ui.mjs +1 -1
- package/package.json +1 -1
- package/preset-editor.mjs +35 -0
- package/terminal-layout.mjs +41 -1
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
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
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:
|
|
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
|
-
//
|
|
2629
|
-
//
|
|
2630
|
-
//
|
|
2631
|
-
|
|
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
|
-
//
|
|
2685
|
-
//
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
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
|
-
|
|
2778
|
-
|
|
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 +
|
|
7087
|
-
|
|
7088
|
-
|
|
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
|
-
|
|
7110
|
-
|
|
7111
|
-
|
|
7112
|
-
|
|
7113
|
-
|
|
7114
|
-
|
|
7115
|
-
console.log(
|
|
7116
|
-
|
|
7117
|
-
|
|
7118
|
-
|
|
7119
|
-
|
|
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"));
|
package/model-setup-ui.mjs
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
+
}
|
package/terminal-layout.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|