@tiens.nguyen/gu-cli 1.0.774 → 1.0.776
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/agent-model-label.mjs +6 -0
- package/clear-state.mjs +1 -1
- package/cli-token.mjs +84 -0
- package/gu-cli.mjs +4 -1
- package/gu-repl.mjs +377 -99
- package/package.json +2 -1
- package/preset-editor.mjs +187 -0
- package/terminal-layout.mjs +24 -0
- package/terminal-viewport.mjs +77 -4
package/agent-model-label.mjs
CHANGED
|
@@ -88,6 +88,11 @@ export function agentModelLabel({ configuredId = "", agentUrl = "", servers = []
|
|
|
88
88
|
if (namesAModel(configuredId)) {
|
|
89
89
|
return { name: String(configuredId).trim(), known: true, reason: "settings" };
|
|
90
90
|
}
|
|
91
|
+
// NOTHING SET (#314): no name and no server — a first install that skipped model setup. Saying
|
|
92
|
+
// "(named by the remote server)" there claimed a server that does not exist.
|
|
93
|
+
if (!String(agentUrl ?? "").trim()) {
|
|
94
|
+
return { name: "", known: false, reason: "not-set" };
|
|
95
|
+
}
|
|
91
96
|
// The sentinel means "ask the server". We can only do that for a server on THIS machine: a
|
|
92
97
|
// client machine points at another Mac, and a local `ps` row is then somebody else's process.
|
|
93
98
|
if (!servesFromThisMachine(agentUrl)) {
|
|
@@ -148,6 +153,7 @@ export function sameModelName(a, b) {
|
|
|
148
153
|
/** Plain-text fallback for each reason we cannot name the model. Never a sentinel. */
|
|
149
154
|
export const UNKNOWN_MODEL_TEXT = Object.freeze({
|
|
150
155
|
remote: "(named by the remote server)",
|
|
156
|
+
"not-set": "not set — /agent-model",
|
|
151
157
|
"no-server": "(no model server running)",
|
|
152
158
|
ambiguous: "(the loaded model)",
|
|
153
159
|
});
|
package/clear-state.mjs
CHANGED
|
@@ -33,7 +33,7 @@ export const STATE_ITEMS = [
|
|
|
33
33
|
{ path: "instance", group: "identity",
|
|
34
34
|
what: "this machine's id", cost: "it gets a new one" },
|
|
35
35
|
{ path: "cli-token.json", group: "identity",
|
|
36
|
-
what: "the gu token (plan + computers)", cost: "a
|
|
36
|
+
what: "the gu token (plan + computers)", cost: "asked for again at the next start (a free trial comes back with the days it had left)" },
|
|
37
37
|
{ path: "prefs.json", group: "identity", what: "CLI preferences", cost: "defaults return" },
|
|
38
38
|
{ path: "wizard-state.json", group: "identity",
|
|
39
39
|
what: "installer progress", cost: "the wizard starts over" },
|
package/cli-token.mjs
CHANGED
|
@@ -185,6 +185,90 @@ export function trialStartedLine(endsAt, site = DEFAULT_SITE) {
|
|
|
185
185
|
`Then get a free token for 1 computer at ${accountUrl} — gu token <paste> links it.`;
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
+
/**
|
|
189
|
+
* How the trial token is shown in the input: its ends, with "…" between (a whole token is ~520
|
|
190
|
+
* characters — five rows of input). Only ever compared, never saved: the real token is kept aside.
|
|
191
|
+
*/
|
|
192
|
+
export function shortToken(token) {
|
|
193
|
+
const t = String(token ?? "");
|
|
194
|
+
return t.length <= 24 ? t : `${t.slice(0, 16)}…${t.slice(-6)}`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* THE TOKEN QUESTION, WITH THE TRIAL AS ITS DEFAULT (#305/#76, placed by #314).
|
|
199
|
+
*
|
|
200
|
+
* Input token: gu1.eyJ2IjoxLCJz…Xk9aQw ← typed in: Enter takes it, or delete it
|
|
201
|
+
* This is a free trial token: 10 days on this computer, until 10 Oct 2026. ← yellow, under
|
|
202
|
+
* For a free token for 1 computer, log in at gu.example/account and create one — or buy Pro …
|
|
203
|
+
*
|
|
204
|
+
* The trial is fetched first (it is the same one every time for this computer). Nothing is saved
|
|
205
|
+
* without an answer: the shown default → the trial; anything else is checked as a pasted token.
|
|
206
|
+
*
|
|
207
|
+
* REQUIRED (the user's rule, #314): "only allow Enter — there is already the default token; if
|
|
208
|
+
* not, the user can delete it and input another". There is no skip: an empty Enter re-offers the
|
|
209
|
+
* default (the person deleted it and changed their mind), and with no default at all (trial used,
|
|
210
|
+
* or offline) it tries the trial again — so the question ends only with a valid token.
|
|
211
|
+
* `required: false` keeps the old two-tries-then-give-up shape for callers that may skip.
|
|
212
|
+
*
|
|
213
|
+
* `ask(label, { prefill, hint, atomic, noCancel })` → the typed line. `atomic` = the default is one
|
|
214
|
+
* unit (one Backspace clears it all); `noCancel` = Esc does nothing. `log(line, kind)` prints:
|
|
215
|
+
* "title" | "ok" | "warn".
|
|
216
|
+
* → { ok: true, kind: "trial" | "pasted" } | { ok: false, why: "skipped" | "refused" }
|
|
217
|
+
*/
|
|
218
|
+
export async function promptForToken({
|
|
219
|
+
ask,
|
|
220
|
+
dir,
|
|
221
|
+
log = (l) => console.log(l),
|
|
222
|
+
hostedBase,
|
|
223
|
+
site = siteUrl(),
|
|
224
|
+
device,
|
|
225
|
+
claim = claimTrial,
|
|
226
|
+
publicKeyB64 = publicKey(),
|
|
227
|
+
required = false,
|
|
228
|
+
maxRounds = 1000, // a bound for tests; a person answers long before this
|
|
229
|
+
}) {
|
|
230
|
+
const accountUrl = `${String(site).replace(/^https?:\/\//, "")}/account`;
|
|
231
|
+
const dev = device === undefined ? await trialDevice().catch(() => null) : device;
|
|
232
|
+
const getOne = `For a free token for 1 computer, log in at ${accountUrl} and create one — or buy Pro there for more computers.`;
|
|
233
|
+
let trial, trialOk, shown, hint;
|
|
234
|
+
const offer = async () => {
|
|
235
|
+
trial = dev ? await claim({ hostedBase, device: dev }) : { ok: false, why: "no-device" };
|
|
236
|
+
trialOk = Boolean(trial.ok && verifyCliToken(trial.token, publicKeyB64).ok);
|
|
237
|
+
shown = trialOk ? shortToken(trial.token) : "";
|
|
238
|
+
hint = trialOk
|
|
239
|
+
? [`This is a free trial token: 10 days on this computer, until ${shortDate(trial.endsAt)}.`, getOne, "Enter uses it — or delete it (one Backspace) and paste your own token."]
|
|
240
|
+
: [
|
|
241
|
+
...(trial.why === "used" && trial.endedAt ? [`The free trial on this computer ended on ${shortDate(trial.endedAt)}.`] : []),
|
|
242
|
+
...(trial.why === "unreachable" ? ["The gu service can't be reached right now — Enter on an empty line tries again."] : []),
|
|
243
|
+
getOne,
|
|
244
|
+
required ? "Paste it here, then Enter." : "Paste it here — or Enter to skip for now.",
|
|
245
|
+
];
|
|
246
|
+
};
|
|
247
|
+
await offer();
|
|
248
|
+
log("This computer needs a gu token before the agent can start — one time only.", "title");
|
|
249
|
+
const ask1 = () => ask("Input token: ", { prefill: shown, hint, atomic: true, noCancel: required });
|
|
250
|
+
for (let round = 0; required ? round < maxRounds : round < 2; round++) {
|
|
251
|
+
const typed = String((await ask1()) ?? "").trim();
|
|
252
|
+
if (!typed) {
|
|
253
|
+
if (!required) return { ok: false, why: "skipped" };
|
|
254
|
+
if (!trialOk) await offer(); // no default: maybe the service is back, or the trial exists now
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (trialOk && (typed === shown || typed === trial.token)) {
|
|
258
|
+
await writeState(dir, { token: trial.token, lastAttemptAt: new Date().toISOString() });
|
|
259
|
+
log(`✓ free trial token saved — 10 days on this computer, until ${shortDate(trial.endsAt)}.`, "ok");
|
|
260
|
+
return { ok: true, kind: "trial" };
|
|
261
|
+
}
|
|
262
|
+
const saved = await saveTokenFromPaste(dir, typed, publicKeyB64);
|
|
263
|
+
if (saved.ok) {
|
|
264
|
+
log("✓ token saved — it renews itself from now on.", "ok");
|
|
265
|
+
return { ok: true, kind: "pasted" };
|
|
266
|
+
}
|
|
267
|
+
log(`That token was not saved: ${saved.reason}.`, "warn");
|
|
268
|
+
}
|
|
269
|
+
return { ok: false, why: "refused" };
|
|
270
|
+
}
|
|
271
|
+
|
|
188
272
|
/** What renewals send about this computer. The raw OS id never leaves the machine — only a salted hash. */
|
|
189
273
|
export async function thisDevice({ platform = process.platform, hostName = osHostname(), machineId, instanceId } = {}) {
|
|
190
274
|
const raw = machineId ?? (await osMachineId({ platform }));
|
package/gu-cli.mjs
CHANGED
|
@@ -1160,7 +1160,10 @@ if (args[0] === "clear" || args[0] === "reset") {
|
|
|
1160
1160
|
const status = await ctl.daemonStatus();
|
|
1161
1161
|
console.log("");
|
|
1162
1162
|
if (status.running) {
|
|
1163
|
-
|
|
1163
|
+
// daemonStatus() reports `pids` (there can be more than one); `pid` never existed, which is
|
|
1164
|
+
// why this always read "(pid ?)" (#314).
|
|
1165
|
+
const pids = status.pids?.length ? status.pids.join(", ") : "";
|
|
1166
|
+
console.log(`This will first stop the running worker${pids ? ` (pid ${pids})` : ""}.`);
|
|
1164
1167
|
}
|
|
1165
1168
|
if (dbItem) {
|
|
1166
1169
|
console.log(
|
package/gu-repl.mjs
CHANGED
|
@@ -98,6 +98,7 @@ import {
|
|
|
98
98
|
inputPhysicalRows,
|
|
99
99
|
buildFooterRows,
|
|
100
100
|
pasteHint,
|
|
101
|
+
hintRows as questionHintRows,
|
|
101
102
|
footerDrawSeq,
|
|
102
103
|
footerEraseSeq,
|
|
103
104
|
clearBlockSeq,
|
|
@@ -879,8 +880,8 @@ const COMMANDS = [
|
|
|
879
880
|
// object {apiUrl, apiKey, agentModel, codingModel, embedModel} and validates every field
|
|
880
881
|
// before writing. A preset file HOLDS its API key (0600, home folder — the settings files'
|
|
881
882
|
// location), so it is marked "gu": "preset" and the two commands never offer each other's files.
|
|
882
|
-
{ name: "/save-preset", usage:
|
|
883
|
-
{ name: "/load-presets", usage: "/load-presets [name]", desc: "pick a saved preset (
|
|
883
|
+
{ name: "/save-preset", usage: "/save-preset <name> [{json}]", desc: "save a named preset — server, API key and the agent/coding/embedding models — in your editor ($EDITOR, nano…), or as one line of JSON; all validated" },
|
|
884
|
+
{ name: "/load-presets", usage: "/load-presets [name]", desc: "pick a saved preset: Enter applies it (asks first) · v view · c copy as a command · j copy JSON · e edit" },
|
|
884
885
|
{ name: "/doctor", desc: "check every configured model ANSWERS — especially the embedder, which a turn only reaches after several steps" },
|
|
885
886
|
{ name: "/tokens", desc: "break down the last prompt sent to the coding model — how much of it was RAG" },
|
|
886
887
|
// ONE name, /new (user, #278). /reset and /clear still work (HIDDEN_COMMANDS) for habit and
|
|
@@ -969,6 +970,10 @@ function printCommands(prefix = "") {
|
|
|
969
970
|
// bar, the live status block, the slash overlay, the pickers, mode-setting escapes — is
|
|
970
971
|
// written through `ui()`, which marks it so it is neither recorded nor held back.
|
|
971
972
|
const transcript = createTranscript();
|
|
973
|
+
// Until the frozen zone first takes the screen, the transcript FOLLOWS THE CURSOR (#314): all of
|
|
974
|
+
// setup's output — prompts that redraw their own line, hints walked back over — went through the
|
|
975
|
+
// transcript, so its moves describe rows the transcript holds. See createTranscript.
|
|
976
|
+
let zoneTookOver = false;
|
|
972
977
|
let _uiDepth = 0;
|
|
973
978
|
let scrollOffset = 0; // lines the view is scrolled BACK from the bottom; 0 = live
|
|
974
979
|
const scrolledBack = () => scrollOffset > 0;
|
|
@@ -1042,7 +1047,7 @@ const contentWrite = (raw, chunk, enc, cb) => {
|
|
|
1042
1047
|
// Any real output commits a held question first (see pendingEcho above).
|
|
1043
1048
|
if (_uiDepth === 0 && pendingEcho !== null) flushPendingEcho();
|
|
1044
1049
|
if (_uiDepth === 0 && (typeof chunk === "string" || Buffer.isBuffer(chunk))) {
|
|
1045
|
-
transcript.append(String(chunk));
|
|
1050
|
+
transcript.append(String(chunk), { followCursor: !zoneTookOver });
|
|
1046
1051
|
// ALWAYS FOLLOW THE BOTTOM. New output means there is something newer to see, so a view
|
|
1047
1052
|
// that was scrolled back comes home first and the line lands where the user is looking.
|
|
1048
1053
|
// Scrolling up is therefore the ONLY way to leave the live tail — which is exactly when the
|
|
@@ -1150,7 +1155,12 @@ rl._ttyWrite = (str, key) => {
|
|
|
1150
1155
|
? makePasteToken(trimmed)
|
|
1151
1156
|
: trimmed.replace(/\n/g, " "); // a short paste stays literal, on one line
|
|
1152
1157
|
// A paste over a selection REPLACES it, exactly as typing over one does — this runs before
|
|
1153
|
-
// the caret is read, so the insert lands where the removed text was.
|
|
1158
|
+
// the caret is read, so the insert lands where the removed text was. So does a paste over an
|
|
1159
|
+
// untouched default (#314: the trial token, replaced by the person's own).
|
|
1160
|
+
if (untouchedDefault()) {
|
|
1161
|
+
rl.line = "";
|
|
1162
|
+
rl.cursor = 0;
|
|
1163
|
+
}
|
|
1154
1164
|
dropSelectedInputText();
|
|
1155
1165
|
// Insert at the caret so a paste mid-sentence lands where the user put it.
|
|
1156
1166
|
const before = rl.line.slice(0, rl.cursor);
|
|
@@ -1231,6 +1241,7 @@ rl._ttyWrite = (str, key) => {
|
|
|
1231
1241
|
// ESC CANCELS A TYPED QUESTION. Below the CPR block deliberately: a probe reply also begins
|
|
1232
1242
|
// with ESC, and testing first would read the terminal's own answer as the user pressing Esc.
|
|
1233
1243
|
if (maybeCancelPrompt(str, key)) return;
|
|
1244
|
+
if (atomicDefaultKey(str, key)) return;
|
|
1234
1245
|
// --- a selection on the input line is EDITABLE, not just copyable (task #177) ----------
|
|
1235
1246
|
// Selecting the line and pressing Backspace is how every text field on the machine clears
|
|
1236
1247
|
// one, and without it the only way to drop a long question is to hold the key down. Handled
|
|
@@ -1477,6 +1488,7 @@ function applyClick({ row, col }) {
|
|
|
1477
1488
|
// A click in the SCROLLING area is not aimed at the caret — it may be aimed at a thought
|
|
1478
1489
|
// block asking to be opened (task #148). Checked before the input-row test so the two
|
|
1479
1490
|
// never compete for the same event.
|
|
1491
|
+
if (!onInputRow(row) && clickCopyButton(row, col)) return;
|
|
1480
1492
|
if (!onInputRow(row) && toggleThoughtAtRow(row)) return;
|
|
1481
1493
|
if (!onInputRow(row) || !clickOffsetAt) return;
|
|
1482
1494
|
rl.cursor = clickOffsetAt(inputRowIndex(row), col);
|
|
@@ -1951,11 +1963,49 @@ function renderSlashOverlay(sel) {
|
|
|
1951
1963
|
slashNeutralizeHistory();
|
|
1952
1964
|
}
|
|
1953
1965
|
slashHintRows = menu.length;
|
|
1966
|
+
if (barPinned()) {
|
|
1967
|
+
paintSlashMenuOverConversation(menu);
|
|
1968
|
+
return;
|
|
1969
|
+
}
|
|
1954
1970
|
regionUi(
|
|
1955
1971
|
slashOverlaySeq(line, rl.cursor ?? line.length, menu, process.stdout.columns)
|
|
1956
1972
|
);
|
|
1957
1973
|
}
|
|
1958
1974
|
|
|
1975
|
+
/**
|
|
1976
|
+
* WHILE THE ZONE IS PINNED the menu is painted over the conversation's BOTTOM ROWS, just above
|
|
1977
|
+
* the zone, and those rows are put back from the transcript when it closes (#314). It used to be
|
|
1978
|
+
* drawn at the OUTPUT position with an input row of its own (">> /…") — the unpinned layout —
|
|
1979
|
+
* and closing repainted that row and left it there: every /command left an empty ">>" row above
|
|
1980
|
+
* its echo, and /exit left ">>" / ">> /exit" in the shell. The typed line is already in the zone.
|
|
1981
|
+
*/
|
|
1982
|
+
let slashDrawn = null; // { from, to }: the absolute rows the menu covers
|
|
1983
|
+
const conversationRowsSeq = (from, to, width) => {
|
|
1984
|
+
let seq = "";
|
|
1985
|
+
for (let r = from; r <= to; r++) seq += `\x1b[${r};1H\x1b[2K` + clipVisible(viewportRowText(r), width - 1);
|
|
1986
|
+
return seq;
|
|
1987
|
+
};
|
|
1988
|
+
function paintSlashMenuOverConversation(menu) {
|
|
1989
|
+
const width = Math.max(20, process.stdout.columns || 80);
|
|
1990
|
+
const zoneTop = viewportRows() - barReserved + 1;
|
|
1991
|
+
const rows = menu.slice(0, Math.max(1, zoneTop - 2)).map((r) => GUTTER + clipVisible(r, width - 1 - GUTTER.length));
|
|
1992
|
+
const from = zoneTop - rows.length;
|
|
1993
|
+
let seq = "\x1b[?7l";
|
|
1994
|
+
// a shorter menu than last time: the rows it no longer covers get the conversation back
|
|
1995
|
+
if (slashDrawn && slashDrawn.from < from) seq += conversationRowsSeq(slashDrawn.from, from - 1, width);
|
|
1996
|
+
rows.forEach((r, i) => (seq += `\x1b[${from + i};1H\x1b[2K` + r));
|
|
1997
|
+
ui(seq + "\x1b[?7h");
|
|
1998
|
+
slashDrawn = { from, to: zoneTop - 1 };
|
|
1999
|
+
paintBar(); // the caret back on the input line
|
|
2000
|
+
}
|
|
2001
|
+
function clearSlashMenuOverConversation() {
|
|
2002
|
+
if (!slashDrawn) return;
|
|
2003
|
+
const width = Math.max(20, process.stdout.columns || 80);
|
|
2004
|
+
ui("\x1b[?7l" + conversationRowsSeq(slashDrawn.from, slashDrawn.to, width) + "\x1b[?7h");
|
|
2005
|
+
slashDrawn = null;
|
|
2006
|
+
if (barPinned()) paintBar();
|
|
2007
|
+
}
|
|
2008
|
+
|
|
1959
2009
|
// Close the overlay: erase the menu below, keep the typed input line, hand drawing back
|
|
1960
2010
|
// to readline. Callers that changed the line to a non-slash value should refresh after.
|
|
1961
2011
|
// Relative moves only, for the same reason as above.
|
|
@@ -1964,9 +2014,12 @@ function closeSlashOverlay() {
|
|
|
1964
2014
|
slashRestoreHistory();
|
|
1965
2015
|
return;
|
|
1966
2016
|
}
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
2017
|
+
if (slashDrawn) clearSlashMenuOverConversation();
|
|
2018
|
+
else {
|
|
2019
|
+
regionUi(
|
|
2020
|
+
slashCloseSeq(rl.line, rl.cursor ?? rl.line.length, process.stdout.columns)
|
|
2021
|
+
);
|
|
2022
|
+
}
|
|
1970
2023
|
slashOverlayActive = false;
|
|
1971
2024
|
slashHintRows = 0;
|
|
1972
2025
|
slashSel = -1;
|
|
@@ -2186,31 +2239,6 @@ let startupRenewDone = false;
|
|
|
2186
2239
|
let licenceBlock = null;
|
|
2187
2240
|
let licenceDecided = false;
|
|
2188
2241
|
|
|
2189
|
-
/**
|
|
2190
|
-
* THE FREE TRIAL (#305): a first install gets ten days on this computer instead of a "paste your
|
|
2191
|
-
* token" stop. Once per computer — the gu service decides, keyed on the OS machine id's hash. → true
|
|
2192
|
-
* when a trial token is now saved. Tried at most once per start by the startup refresh, and again
|
|
2193
|
-
* right before the token question (a first start may have been offline).
|
|
2194
|
-
*/
|
|
2195
|
-
let trialTried = false;
|
|
2196
|
-
let trialEndedAt = ""; // this computer's trial has ended — the token question says so
|
|
2197
|
-
async function tryTrialToken(ct, dir, { announce = true } = {}) {
|
|
2198
|
-
const device = await ct.trialDevice().catch(() => null);
|
|
2199
|
-
if (!device) return false; // no stable machine id → no trial (see trialDevice)
|
|
2200
|
-
const { DEFAULT_API_BASE } = await import("./device-login.mjs");
|
|
2201
|
-
const hostedBase = String(process.env.GU_TOKEN_API_BASE ?? "").trim() || DEFAULT_API_BASE;
|
|
2202
|
-
const r = await ct.claimTrial({ hostedBase, device });
|
|
2203
|
-
if (r.ok && ct.verifyCliToken(r.token, ct.publicKey()).ok) {
|
|
2204
|
-
await ct.writeState(dir, { token: r.token, lastAttemptAt: new Date().toISOString() });
|
|
2205
|
-
if (announce) {
|
|
2206
|
-
console.log(GUTTER + accent(r.created ? ct.trialStartedLine(r.endsAt, ct.siteUrl()) : `Free trial on this computer until ${ct.shortDate(r.endsAt)}.`));
|
|
2207
|
-
}
|
|
2208
|
-
return true;
|
|
2209
|
-
}
|
|
2210
|
-
if (r.why === "used" && r.endedAt) trialEndedAt = r.endedAt;
|
|
2211
|
-
return false;
|
|
2212
|
-
}
|
|
2213
|
-
|
|
2214
2242
|
async function refreshCliToken() {
|
|
2215
2243
|
if (tokenBusy) return;
|
|
2216
2244
|
tokenBusy = true;
|
|
@@ -2225,11 +2253,6 @@ async function refreshCliToken() {
|
|
|
2225
2253
|
const mode = isClientExpert() ? "expert" : "client";
|
|
2226
2254
|
const site = ct.siteUrl();
|
|
2227
2255
|
let state = await ct.readState(dir);
|
|
2228
|
-
// #305: private mode with no usable token → this computer's free trial, before anything asks.
|
|
2229
|
-
if (mode === "expert" && !trialTried && !(state.token && ct.verifyCliToken(state.token, pk).ok)) {
|
|
2230
|
-
trialTried = true;
|
|
2231
|
-
if (await tryTrialToken(ct, dir)) state = await ct.readState(dir);
|
|
2232
|
-
}
|
|
2233
2256
|
const now = new Date();
|
|
2234
2257
|
const noticeNow = () =>
|
|
2235
2258
|
ct.barNotice({ token: state.token, publicKeyB64: pk, mode, now: new Date(), attempted: Boolean(state.lastAttemptAt), site });
|
|
@@ -2511,6 +2534,9 @@ const footerRows = () =>
|
|
|
2511
2534
|
// Task #14: the skipped-setup warning — persistent (not a timed notice), red, its own row.
|
|
2512
2535
|
missing: missingNoticeText() ? red(missingNoticeText()) : "",
|
|
2513
2536
|
});
|
|
2537
|
+
// An ask()'s hint lines (#305), drawn in yellow under its input while it waits. Plain text.
|
|
2538
|
+
let askHint = [];
|
|
2539
|
+
const askHintRows = (width) => questionHintRows(askHint, { width, gutter: GUTTER, paint: yellow });
|
|
2514
2540
|
// True only while the MAIN loop's ">> " prompt is waiting for input. The bar belongs to that
|
|
2515
2541
|
// prompt, so it must never appear under an ask()/askSecret() question or during a turn.
|
|
2516
2542
|
let footerActive = false;
|
|
@@ -2557,6 +2583,7 @@ const barPinned = () => barReserved > 0;
|
|
|
2557
2583
|
/** Reserve the rows (or re-cut them after a resize) and paint the bar into them. */
|
|
2558
2584
|
function pinBar() {
|
|
2559
2585
|
if (!canPinBar()) return;
|
|
2586
|
+
zoneTookOver = true;
|
|
2560
2587
|
const rows = frozenRowCount();
|
|
2561
2588
|
// Re-cutting keeps the OUTPUT position that is already in the save slot (preserveCaret:false);
|
|
2562
2589
|
// paintBar() below puts the caret back on the input line.
|
|
@@ -2652,7 +2679,7 @@ function frozenZone() {
|
|
|
2652
2679
|
selection: secretInput ? null : inputSelection,
|
|
2653
2680
|
highlight: highlightRow,
|
|
2654
2681
|
});
|
|
2655
|
-
const rows = status.concat(zone.rows, footerRows());
|
|
2682
|
+
const rows = status.concat(zone.rows, askHintRows(width), footerRows());
|
|
2656
2683
|
// THE SEPARATOR IS NO LONGER ROW 0. With the status block prepended it sits `status.length`
|
|
2657
2684
|
// rows down, and every affordance below writes to it by index — a bare rows[0] would paint
|
|
2658
2685
|
// the jump hint over the thought line and hit-test the wrong row.
|
|
@@ -2787,7 +2814,7 @@ let inputSelection = null;
|
|
|
2787
2814
|
*/
|
|
2788
2815
|
const maxInputRows = () => {
|
|
2789
2816
|
const MIN_VIEW = 4;
|
|
2790
|
-
const spare = viewportRows() - footerRows().length - INPUT_HEAD_ROWS - MIN_VIEW;
|
|
2817
|
+
const spare = viewportRows() - footerRows().length - askHintRows(process.stdout.columns || 80).length - INPUT_HEAD_ROWS - MIN_VIEW;
|
|
2791
2818
|
return Math.max(1, Math.min(10, spare));
|
|
2792
2819
|
};
|
|
2793
2820
|
/**
|
|
@@ -3149,7 +3176,7 @@ async function copyAtRow(row) {
|
|
|
3149
3176
|
* `trailingBlanks` is how many blank lines the write left BELOW the block — see
|
|
3150
3177
|
* registeredRange, where getting it wrong silently shifts the whole range by one.
|
|
3151
3178
|
*/
|
|
3152
|
-
function registerCopyBlock(rowCount, text, trailingBlanks = 0) {
|
|
3179
|
+
function registerCopyBlock(rowCount, text, trailingBlanks = 0, extra = {}) {
|
|
3153
3180
|
if (!rowCount || !String(text ?? "").trim()) return;
|
|
3154
3181
|
const { from, to } = registeredRange({
|
|
3155
3182
|
dropped: transcript.dropped(),
|
|
@@ -3157,7 +3184,9 @@ function registerCopyBlock(rowCount, text, trailingBlanks = 0) {
|
|
|
3157
3184
|
rowCount,
|
|
3158
3185
|
trailingBlanks,
|
|
3159
3186
|
});
|
|
3160
|
-
|
|
3187
|
+
// `extra.button` = { from, to } columns on the block's FIRST row that copy on a single click
|
|
3188
|
+
// (the preset block's "⧉ copy", #312); `extra.note` is the badge it shows.
|
|
3189
|
+
copyBlocks.push({ from, to, text: String(text), ...extra });
|
|
3161
3190
|
if (copyBlocks.length > 200) copyBlocks = copyBlocks.slice(-200);
|
|
3162
3191
|
}
|
|
3163
3192
|
|
|
@@ -3397,6 +3426,30 @@ function paintCopyBadge(row, place, ok = true) {
|
|
|
3397
3426
|
}, 2000);
|
|
3398
3427
|
}
|
|
3399
3428
|
|
|
3429
|
+
/**
|
|
3430
|
+
* A single click on a block's "⧉ copy" (#312) → copy its source text. → true when the click was
|
|
3431
|
+
* on a button (handled), false to let the click do whatever else it does.
|
|
3432
|
+
*/
|
|
3433
|
+
function clickCopyButton(row, col) {
|
|
3434
|
+
if (!barPinned() || !copyBlocks.length) return false;
|
|
3435
|
+
const width = Math.max(20, process.stdout.columns || 80) - 1;
|
|
3436
|
+
const owners = displayRowOwners(transcript.lines(), width);
|
|
3437
|
+
const logical = lineAtViewportRow(owners, row, transcriptRows(), scrollOffset);
|
|
3438
|
+
if (logical < 0) return false;
|
|
3439
|
+
const abs = transcript.dropped() + logical;
|
|
3440
|
+
const b = blockAt(copyBlocks, abs);
|
|
3441
|
+
if (!b?.button || abs !== b.from || col - 1 < b.button.from || col - 1 >= b.button.to) return false;
|
|
3442
|
+
void (async () => {
|
|
3443
|
+
try {
|
|
3444
|
+
const res = await writeClipboard(b.text);
|
|
3445
|
+
showCopyBadge(row, res.ok ? b.note || "copied" : copyBadge({ ok: false, why: res.why }), res.ok);
|
|
3446
|
+
} catch {
|
|
3447
|
+
/* a failed copy must never take the session down */
|
|
3448
|
+
}
|
|
3449
|
+
})();
|
|
3450
|
+
return true;
|
|
3451
|
+
}
|
|
3452
|
+
|
|
3400
3453
|
function copyTargetAtRow(row) {
|
|
3401
3454
|
if (!barPinned()) return null;
|
|
3402
3455
|
const width = Math.max(20, process.stdout.columns || 80) - 1;
|
|
@@ -4263,12 +4316,35 @@ let askWaiting = false;
|
|
|
4263
4316
|
*/
|
|
4264
4317
|
function maybeCancelPrompt(str, key) {
|
|
4265
4318
|
if (!shouldCancelPrompt(askWaiting, key)) return false;
|
|
4319
|
+
if (askNoCancel) return true; // a question with no way round it (#314): Esc does nothing
|
|
4266
4320
|
rl.line = "";
|
|
4267
4321
|
rl.cursor = 0;
|
|
4268
4322
|
submitSyntheticLine(CANCEL_LINE);
|
|
4269
4323
|
return true;
|
|
4270
4324
|
}
|
|
4271
|
-
|
|
4325
|
+
// #314: the default typed into an ask() is ONE UNIT while untouched (atomicDefaultKey), and a
|
|
4326
|
+
// question may refuse Esc. Both last only for that question.
|
|
4327
|
+
let askAtomic = "";
|
|
4328
|
+
let askNoCancel = false;
|
|
4329
|
+
const untouchedDefault = () => Boolean(askAtomic) && rl.line === askAtomic;
|
|
4330
|
+
/**
|
|
4331
|
+
* A key on an untouched default. Backspace / Delete / Ctrl+U / Ctrl+W clear ALL of it — it is a
|
|
4332
|
+
* stand-in (a shortened token), not text to edit a character at a time — and a typed character
|
|
4333
|
+
* replaces it. → true when the key was consumed.
|
|
4334
|
+
*/
|
|
4335
|
+
function atomicDefaultKey(str, key) {
|
|
4336
|
+
if (!untouchedDefault()) return false;
|
|
4337
|
+
const clears = key?.name === "backspace" || key?.name === "delete" || (key?.ctrl && (key.name === "u" || key.name === "w"));
|
|
4338
|
+
const printable = typeof str === "string" && str.length === 1 && str >= " " && !key?.ctrl && !key?.meta;
|
|
4339
|
+
if (!clears && !printable) return false;
|
|
4340
|
+
rl.line = "";
|
|
4341
|
+
rl.cursor = 0;
|
|
4342
|
+
inputSelection = null;
|
|
4343
|
+
if (!clears) return false; // the character itself is inserted by readline, into the empty line
|
|
4344
|
+
rl._refreshLine();
|
|
4345
|
+
return true;
|
|
4346
|
+
}
|
|
4347
|
+
const ask = async (q, { prefill = "", hint = [], atomic = false, noCancel = false } = {}) => {
|
|
4272
4348
|
// Route the question through readline's own prompt so its redraw logic stays
|
|
4273
4349
|
// consistent (a raw stdout write here would re-create the stale-cursor artifact
|
|
4274
4350
|
// that terminal:true mode is designed to avoid). Restored to ">> " afterwards.
|
|
@@ -4333,6 +4409,15 @@ const ask = async (q) => {
|
|
|
4333
4409
|
// the turn's empty-prompt behaviour must come back.
|
|
4334
4410
|
const wasVisible = inputVisible;
|
|
4335
4411
|
inputVisible = true;
|
|
4412
|
+
// #305: a default typed in for the person to take (Enter) or delete, and a hint drawn under
|
|
4413
|
+
// the input (frozenZone) — both only for this question.
|
|
4414
|
+
askHint = hint;
|
|
4415
|
+
askAtomic = atomic ? String(prefill) : "";
|
|
4416
|
+
askNoCancel = noCancel;
|
|
4417
|
+
if (prefill) {
|
|
4418
|
+
rl.line = String(prefill);
|
|
4419
|
+
rl.cursor = rl.line.length;
|
|
4420
|
+
}
|
|
4336
4421
|
// ELLIPSIS, so a truncation is VISIBLE. Silently cutting "…[https://ollama2.gomarsic.cc/v1]:"
|
|
4337
4422
|
// to "…[https:/" hides the default Enter would accept, which is worse than not showing it —
|
|
4338
4423
|
// the reader cannot tell whether the value is short or the row lied. Callers should keep the
|
|
@@ -4350,19 +4435,29 @@ const ask = async (q) => {
|
|
|
4350
4435
|
return (a ?? "").trim();
|
|
4351
4436
|
} finally {
|
|
4352
4437
|
askWaiting = false;
|
|
4438
|
+
askHint = [];
|
|
4439
|
+
askAtomic = "";
|
|
4440
|
+
askNoCancel = false;
|
|
4353
4441
|
inlinePrompt = "";
|
|
4354
4442
|
inputVisible = wasVisible;
|
|
4355
4443
|
paintBar(); // put ">> " back, or the next question wears this one's label
|
|
4356
4444
|
}
|
|
4357
4445
|
}
|
|
4446
|
+
// Unpinned (piped output, a short window): no frozen zone to draw under, so the hint goes above.
|
|
4447
|
+
for (const l of hint) console.log(GUTTER + yellow(l));
|
|
4358
4448
|
rl.setPrompt(gutterLines(q));
|
|
4359
4449
|
rl.prompt();
|
|
4450
|
+
if (prefill) rl.write(String(prefill));
|
|
4451
|
+
askAtomic = atomic ? String(prefill) : "";
|
|
4452
|
+
askNoCancel = noCancel;
|
|
4360
4453
|
let a;
|
|
4361
4454
|
try {
|
|
4362
4455
|
askWaiting = true;
|
|
4363
4456
|
a = await nextLine();
|
|
4364
4457
|
} finally {
|
|
4365
4458
|
askWaiting = false;
|
|
4459
|
+
askAtomic = "";
|
|
4460
|
+
askNoCancel = false;
|
|
4366
4461
|
}
|
|
4367
4462
|
rl.setPrompt(PROMPT);
|
|
4368
4463
|
// Esc here too: the unpinned path is what a short window and a piped stdout get, and a cancel
|
|
@@ -6712,24 +6807,70 @@ function settingsArg(line, word) {
|
|
|
6712
6807
|
}
|
|
6713
6808
|
|
|
6714
6809
|
/**
|
|
6715
|
-
*
|
|
6716
|
-
*
|
|
6717
|
-
*
|
|
6718
|
-
*
|
|
6719
|
-
*
|
|
6720
|
-
*
|
|
6810
|
+
* Give the terminal to a full-screen program (an editor, #312) and take it back afterwards.
|
|
6811
|
+
*
|
|
6812
|
+
* gu owns the screen while it runs: a pinned frozen zone (DECSTBM), raw mode, mouse reporting and
|
|
6813
|
+
* bracketed paste. An editor started on top of that would get gu's scroll region and mouse bytes,
|
|
6814
|
+
* and gu's readline would keep reading the keys meant for the editor. Everything is switched off
|
|
6815
|
+
* first and switched back on — and the view repainted from the transcript — whatever happens.
|
|
6816
|
+
*/
|
|
6817
|
+
function withTerminalHandedOver(run) {
|
|
6818
|
+
const wasPinned = barPinned();
|
|
6819
|
+
const hadClick = _clickEnabled;
|
|
6820
|
+
disableMouse();
|
|
6821
|
+
disableClickToPosition();
|
|
6822
|
+
ui(PASTE_MODE_OFF);
|
|
6823
|
+
unpinBar();
|
|
6824
|
+
const wasRaw = Boolean(process.stdin.isRaw);
|
|
6825
|
+
try {
|
|
6826
|
+
process.stdin.setRawMode?.(false);
|
|
6827
|
+
} catch {
|
|
6828
|
+
/* not a TTY — nothing to switch */
|
|
6829
|
+
}
|
|
6830
|
+
rl.pause();
|
|
6831
|
+
try {
|
|
6832
|
+
return run();
|
|
6833
|
+
} finally {
|
|
6834
|
+
try {
|
|
6835
|
+
process.stdin.setRawMode?.(wasRaw);
|
|
6836
|
+
} catch {
|
|
6837
|
+
/* not a TTY */
|
|
6838
|
+
}
|
|
6839
|
+
rl.resume();
|
|
6840
|
+
ui(PASTE_MODE_ON);
|
|
6841
|
+
if (hadClick) enableClickToPosition();
|
|
6842
|
+
if (wasPinned) {
|
|
6843
|
+
ui("\x1b[2J");
|
|
6844
|
+
pinBar();
|
|
6845
|
+
paintLiveTail();
|
|
6846
|
+
}
|
|
6847
|
+
}
|
|
6848
|
+
}
|
|
6849
|
+
|
|
6850
|
+
/**
|
|
6851
|
+
* /save-preset <name> — THE PRESET IN AN EDITOR (#312). Opens $VISUAL / $EDITOR / nano / vi
|
|
6852
|
+
* (Notepad on Windows) on a temp file filled in with the preset being edited or the current
|
|
6853
|
+
* setup; save and quit stores it, quit without a change cancels. A wrong value is named and the
|
|
6854
|
+
* editor reopens with the person's OWN text, never the template again.
|
|
6855
|
+
*
|
|
6856
|
+
* /save-preset <name> <json> — the one-line form (#6), kept for scripts and for pasting the
|
|
6857
|
+
* command /load-presets copies. Both validate everything before a byte is written. A preset SAVES
|
|
6858
|
+
* the API key by design (unlike /save-settings) — stored 0600 in the home folder, marked
|
|
6859
|
+
* "gu": "preset" so the two commands never offer each other's files.
|
|
6721
6860
|
*/
|
|
6722
6861
|
async function runSavePresetCommand(line) {
|
|
6723
6862
|
const pr = await import("./presets-transfer.mjs");
|
|
6724
6863
|
const home = homedir();
|
|
6725
6864
|
console.log("");
|
|
6726
|
-
const usage =
|
|
6727
|
-
|
|
6728
|
-
const rest = line.trim().slice("/save-preset".length).trim();
|
|
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();
|
|
6729
6867
|
if (!rest) {
|
|
6730
|
-
|
|
6731
|
-
|
|
6732
|
-
|
|
6868
|
+
const typed = String((await ask("preset name: ")) ?? "").trim();
|
|
6869
|
+
if (!typed) {
|
|
6870
|
+
console.log(GUTTER + dim("cancelled — nothing saved.") + "\n");
|
|
6871
|
+
return;
|
|
6872
|
+
}
|
|
6873
|
+
rest = typed;
|
|
6733
6874
|
}
|
|
6734
6875
|
const space = rest.search(/\s/);
|
|
6735
6876
|
const name = space === -1 ? rest : rest.slice(0, space);
|
|
@@ -6739,22 +6880,68 @@ async function runSavePresetCommand(line) {
|
|
|
6739
6880
|
console.log("");
|
|
6740
6881
|
return;
|
|
6741
6882
|
}
|
|
6742
|
-
|
|
6743
|
-
|
|
6883
|
+
const file = pr.presetFilePath(name, home);
|
|
6884
|
+
const store = (preset) => {
|
|
6885
|
+
writeFileSync(file, JSON.stringify(pr.buildPreset({ name, preset, host: hostname() }), null, 2) + "\n", { mode: 0o600 });
|
|
6886
|
+
console.log(GUTTER + `Preset "${name}" saved to ${file}`);
|
|
6887
|
+
console.log(GUTTER + dim("server + api key + agent, coding and embedding models — the key is IN this file, keep it private."));
|
|
6744
6888
|
console.log("");
|
|
6889
|
+
};
|
|
6890
|
+
if (json) {
|
|
6891
|
+
const checked = pr.validatePreset(json);
|
|
6892
|
+
if (!checked.ok) {
|
|
6893
|
+
console.log(GUTTER + `Preset not saved — ${checked.problem}`);
|
|
6894
|
+
console.log("");
|
|
6895
|
+
return;
|
|
6896
|
+
}
|
|
6897
|
+
store(checked.preset);
|
|
6745
6898
|
return;
|
|
6746
6899
|
}
|
|
6747
|
-
|
|
6748
|
-
|
|
6749
|
-
|
|
6900
|
+
|
|
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");
|
|
6903
|
+
const editor = ed.pickEditor();
|
|
6904
|
+
if (!editor || !process.stdin.isTTY) {
|
|
6905
|
+
console.log(GUTTER + (editor ? "an editor needs a terminal — " : "no editor found (set $EDITOR, e.g. export EDITOR=nano) — ") + usage);
|
|
6750
6906
|
console.log("");
|
|
6751
6907
|
return;
|
|
6752
6908
|
}
|
|
6753
|
-
|
|
6754
|
-
|
|
6755
|
-
|
|
6756
|
-
|
|
6757
|
-
|
|
6909
|
+
let start = null;
|
|
6910
|
+
if (existsSync(file)) {
|
|
6911
|
+
try {
|
|
6912
|
+
const doc = JSON.parse(readFileSync(file, "utf8"));
|
|
6913
|
+
if (doc?.gu === "preset") start = doc;
|
|
6914
|
+
} catch {
|
|
6915
|
+
/* unreadable — start from the current setup instead */
|
|
6916
|
+
}
|
|
6917
|
+
}
|
|
6918
|
+
if (!start) start = ed.presetFromSettings((await fetchWorkerSettings()) ?? {});
|
|
6919
|
+
const initial = ed.editorText(name, start);
|
|
6920
|
+
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
|
+
let text = initial;
|
|
6922
|
+
for (;;) {
|
|
6923
|
+
const r = withTerminalHandedOver(() => ed.editText(text, { editor, fileName: `${name}.json` }));
|
|
6924
|
+
if (!r.ok) {
|
|
6925
|
+
console.log(GUTTER + `Preset not saved — ${r.why}.`);
|
|
6926
|
+
console.log("");
|
|
6927
|
+
return;
|
|
6928
|
+
}
|
|
6929
|
+
if (r.text === initial) {
|
|
6930
|
+
console.log(GUTTER + dim("Not saved — nothing changed.") + "\n");
|
|
6931
|
+
return;
|
|
6932
|
+
}
|
|
6933
|
+
const parsed = ed.parseEdited(r.text);
|
|
6934
|
+
if (parsed.ok) {
|
|
6935
|
+
store(parsed.preset);
|
|
6936
|
+
return;
|
|
6937
|
+
}
|
|
6938
|
+
console.log(GUTTER + yellow(`Not saved yet — ${parsed.problem}`));
|
|
6939
|
+
if (!(await askYesNo("Edit again?", true))) {
|
|
6940
|
+
console.log(GUTTER + dim("Not saved.") + "\n");
|
|
6941
|
+
return;
|
|
6942
|
+
}
|
|
6943
|
+
text = r.text; // their text, with the mistake in it — not the template again
|
|
6944
|
+
}
|
|
6758
6945
|
}
|
|
6759
6946
|
|
|
6760
6947
|
/**
|
|
@@ -6887,26 +7074,61 @@ async function runLoadPresetsCommand(line) {
|
|
|
6887
7074
|
return;
|
|
6888
7075
|
}
|
|
6889
7076
|
} else {
|
|
7077
|
+
// THE PICKER DOES MORE THAN APPLY (#312): v shows the whole preset (key masked) with a
|
|
7078
|
+
// clickable "⧉ copy", c / j copy it as the /save-preset command or as JSON (key included), e
|
|
7079
|
+
// opens it in the editor. Only Enter applies. Each of the others ENDS the command: the mouse
|
|
7080
|
+
// is gu's only at the ">> " prompt (never while a picker is up), so a "⧉ copy" printed under a
|
|
7081
|
+
// picker that stayed open could not be clicked until it closed.
|
|
7082
|
+
const ed = await import("./preset-editor.mjs");
|
|
6890
7083
|
const found = pr.presetFilesIn(listJsonFiles(home));
|
|
6891
7084
|
if (found.length === 0) {
|
|
6892
|
-
console.log(GUTTER + `no presets in ${home} — save one with /save-preset <name
|
|
7085
|
+
console.log(GUTTER + `no presets in ${home} — save one with /save-preset <name>`);
|
|
6893
7086
|
console.log("");
|
|
6894
7087
|
return;
|
|
6895
7088
|
}
|
|
6896
|
-
|
|
6897
|
-
|
|
6898
|
-
|
|
6899
|
-
console.log(GUTTER + `one preset here: ${found[0]}`);
|
|
6900
|
-
} else {
|
|
6901
|
-
console.log(dim(` Presets in ${home} (${PICK_HINT}):`));
|
|
6902
|
-
pick = await pickFromList(found.map((n) => ({ label: n })));
|
|
6903
|
-
}
|
|
6904
|
-
if (pick < 0) {
|
|
7089
|
+
console.log(dim(` Presets in ${home} (${ed.PICKER_HINT}):`));
|
|
7090
|
+
const pick = await pickFromList(found, 0, { keys: ed.PICKER_KEYS });
|
|
7091
|
+
if (!pick || pick.index < 0) {
|
|
6905
7092
|
console.log(GUTTER + "load cancelled.");
|
|
6906
7093
|
console.log("");
|
|
6907
7094
|
return;
|
|
6908
7095
|
}
|
|
6909
|
-
file = pr.presetFilePath(found[pick], home);
|
|
7096
|
+
file = pr.presetFilePath(found[pick.index], home);
|
|
7097
|
+
if (pick.action === "edit") {
|
|
7098
|
+
await runSavePresetCommand(`/save-preset ${found[pick.index]}`);
|
|
7099
|
+
return;
|
|
7100
|
+
}
|
|
7101
|
+
if (pick.action !== "select") {
|
|
7102
|
+
const shown = pr.validatePresetFile(readFileSync(file, "utf8"));
|
|
7103
|
+
if (!shown.ok) {
|
|
7104
|
+
console.log(GUTTER + `Can't show it — ${shown.problem}`);
|
|
7105
|
+
console.log("");
|
|
7106
|
+
return;
|
|
7107
|
+
}
|
|
7108
|
+
const pname = shown.name || found[pick.index];
|
|
7109
|
+
const command = ed.presetCopyCommand(pname, shown.preset);
|
|
7110
|
+
if (pick.action === "view") {
|
|
7111
|
+
const block = ed.presetBlock(pname, shown.preset, { width: process.stdout.columns || 80, gutter: GUTTER });
|
|
7112
|
+
const [top, ...restRows] = block.rows;
|
|
7113
|
+
const cut = block.button.from;
|
|
7114
|
+
console.log(dim(top.slice(0, cut)) + accent(top.slice(cut, block.button.to)) + dim(top.slice(block.button.to)));
|
|
7115
|
+
for (const r of restRows) console.log(r);
|
|
7116
|
+
registerCopyBlock(block.rows.length, command, 0, { button: block.button, note: "✓ copied — includes the API key" });
|
|
7117
|
+
console.log(GUTTER + dim("click ⧉ copy for a /save-preset command to paste anywhere — it includes the real key."));
|
|
7118
|
+
console.log(GUTTER + dim(`/load-presets ${pname} applies it.`));
|
|
7119
|
+
console.log("");
|
|
7120
|
+
return;
|
|
7121
|
+
}
|
|
7122
|
+
const res = await writeClipboard(pick.action === "json" ? ed.presetCopyJson(shown.preset) : command);
|
|
7123
|
+
console.log(
|
|
7124
|
+
GUTTER +
|
|
7125
|
+
(res.ok
|
|
7126
|
+
? accent(`✓ copied ${pick.action === "json" ? "the JSON" : "the /save-preset command"} for "${pname}" — includes the API key.`)
|
|
7127
|
+
: yellow(`couldn't copy (${res.why}) — the preset is in ${file}.`))
|
|
7128
|
+
);
|
|
7129
|
+
console.log("");
|
|
7130
|
+
return;
|
|
7131
|
+
}
|
|
6910
7132
|
}
|
|
6911
7133
|
const checked = pr.validatePresetFile(readFileSync(file, "utf8"));
|
|
6912
7134
|
if (!checked.ok) {
|
|
@@ -9148,6 +9370,10 @@ let listAllowDelete = false;
|
|
|
9148
9370
|
// Set by the ctrl+d branch and read by finishList, so the resolved value can say WHICH action
|
|
9149
9371
|
// happened rather than encoding it into the index and hoping every caller decodes it.
|
|
9150
9372
|
let listDeleteRequested = false;
|
|
9373
|
+
// Single letters that finish a single-select picker with an ACTION on the highlighted row, e.g.
|
|
9374
|
+
// /load-presets' { v: "view", c: "copy" } (#312). Null unless a caller asks, like ctrl+d above.
|
|
9375
|
+
let listKeys = null;
|
|
9376
|
+
let listKeyAction = "";
|
|
9151
9377
|
function drawList(redraw) {
|
|
9152
9378
|
// Gutter (task #128 Phase 2): inset each row after its line-clear. Clipped to width-1 like
|
|
9153
9379
|
// every other in-place block: a long item (a model id on a narrow terminal) that wrapped
|
|
@@ -9210,6 +9436,10 @@ function finishList(index) {
|
|
|
9210
9436
|
const deleting = listDeleteRequested;
|
|
9211
9437
|
listAllowDelete = false;
|
|
9212
9438
|
listDeleteRequested = false;
|
|
9439
|
+
const keyed = listKeys;
|
|
9440
|
+
const keyAction = listKeyAction;
|
|
9441
|
+
listKeys = null;
|
|
9442
|
+
listKeyAction = "";
|
|
9213
9443
|
// Multi-select resolves with the TICKED indices — and with null, not [], when cancelled: an
|
|
9214
9444
|
// empty selection is a legitimate answer ("offer none of them"), so it must be distinguishable
|
|
9215
9445
|
// from Escape, or cancelling would silently save "none".
|
|
@@ -9223,7 +9453,9 @@ function finishList(index) {
|
|
|
9223
9453
|
: checked
|
|
9224
9454
|
: allowDelete
|
|
9225
9455
|
? { index, action: index >= 0 && deleting ? "delete" : "select" }
|
|
9226
|
-
:
|
|
9456
|
+
: keyed
|
|
9457
|
+
? { index, action: index >= 0 ? keyAction || "select" : "cancel" }
|
|
9458
|
+
: index
|
|
9227
9459
|
);
|
|
9228
9460
|
}
|
|
9229
9461
|
}
|
|
@@ -9278,6 +9510,9 @@ function onListKey(key) {
|
|
|
9278
9510
|
// Enter still selects; nothing about the ordinary path changes.
|
|
9279
9511
|
listDeleteRequested = true;
|
|
9280
9512
|
finishList(listSel);
|
|
9513
|
+
} else if (listKeys && !listMulti && !key.ctrl && !key.meta && key.name && Object.hasOwn(listKeys, key.name)) {
|
|
9514
|
+
listKeyAction = listKeys[key.name];
|
|
9515
|
+
finishList(listSel);
|
|
9281
9516
|
} else if (key.name === "return" || key.name === "enter") {
|
|
9282
9517
|
finishList(listSel);
|
|
9283
9518
|
} else if (key.name === "escape" || (key.ctrl && key.name === "c")) {
|
|
@@ -9322,6 +9557,8 @@ function pickFromList(items, initialSel = 0, opts = {}) {
|
|
|
9322
9557
|
listMulti = false; // explicit: a cancelled multi-select must not leak its mode into this one
|
|
9323
9558
|
listAllowDelete = opts.allowDelete === true;
|
|
9324
9559
|
listDeleteRequested = false;
|
|
9560
|
+
listKeys = opts.keys && typeof opts.keys === "object" ? opts.keys : null;
|
|
9561
|
+
listKeyAction = "";
|
|
9325
9562
|
listChecked = new Set();
|
|
9326
9563
|
listItems = items;
|
|
9327
9564
|
listTop = 0;
|
|
@@ -9758,31 +9995,46 @@ async function ensureTokenForTurn() {
|
|
|
9758
9995
|
const dir = join(homedir(), ".gonext");
|
|
9759
9996
|
const state = await ct.readState(dir);
|
|
9760
9997
|
if (!ct.turnNeedsToken({ privateMode: true, token: state.token })) return true;
|
|
9761
|
-
|
|
9762
|
-
//
|
|
9763
|
-
if (await
|
|
9764
|
-
console.log("");
|
|
9765
|
-
return true;
|
|
9766
|
-
}
|
|
9767
|
-
if (trialEndedAt) console.log(GUTTER + dim(`The free trial on this computer ended on ${ct.shortDate(trialEndedAt)}.`));
|
|
9768
|
-
for (const l of ct.tokenAskLines()) console.log(GUTTER + (l.startsWith(" ") ? dim(l) : yellow(l)));
|
|
9769
|
-
for (let attempt = 0; attempt < 2; attempt++) {
|
|
9770
|
-
const pasted = String((await ask(" gu token: ")) ?? "").trim();
|
|
9771
|
-
if (!pasted) {
|
|
9772
|
-
console.log(GUTTER + dim("Not sent. Run `gu token <paste>` any time, then send your message again.") + "\n");
|
|
9773
|
-
return false;
|
|
9774
|
-
}
|
|
9775
|
-
const saved = await ct.saveTokenFromPaste(dir, pasted);
|
|
9776
|
-
if (saved.ok) {
|
|
9777
|
-
console.log(GUTTER + accent("✓ token saved") + dim(" — it renews itself from now on.") + "\n");
|
|
9778
|
-
return true;
|
|
9779
|
-
}
|
|
9780
|
-
console.log(GUTTER + yellow(`That token was not saved: ${saved.reason}.`));
|
|
9781
|
-
}
|
|
9998
|
+
// The FALLBACK (#314): the token is normally settled at the first ">>" (askTokenAtStart); this
|
|
9999
|
+
// catches a token that went missing mid-session (removed, or `gu token` pasted a bad one).
|
|
10000
|
+
if (await askForCliToken(ct, dir)) return true;
|
|
9782
10001
|
console.log(GUTTER + dim("Not sent. Run `gu token <paste>` any time, then send your message again.") + "\n");
|
|
9783
10002
|
return false;
|
|
9784
10003
|
}
|
|
9785
10004
|
|
|
10005
|
+
/**
|
|
10006
|
+
* THE TOKEN QUESTION, IN THE INPUT ROW (#305/#76 → #314). "Input token: <the trial token>" stands
|
|
10007
|
+
* where ">> " would, so no question can be typed until there is a token; Enter is the only way on
|
|
10008
|
+
* (Esc does nothing), one Backspace clears the default for a paste, and a valid token turns the
|
|
10009
|
+
* row back into ">> ". → true once a token is saved.
|
|
10010
|
+
*/
|
|
10011
|
+
async function askForCliToken(ct, dir) {
|
|
10012
|
+
console.log("");
|
|
10013
|
+
const { DEFAULT_API_BASE } = await import("./device-login.mjs");
|
|
10014
|
+
const r = await ct.promptForToken({
|
|
10015
|
+
ask: (label, opts) => ask(label, opts),
|
|
10016
|
+
dir,
|
|
10017
|
+
required: true,
|
|
10018
|
+
hostedBase: String(process.env.GU_TOKEN_API_BASE ?? "").trim() || DEFAULT_API_BASE,
|
|
10019
|
+
log: (l, kind) =>
|
|
10020
|
+
console.log(GUTTER + (kind === "title" ? yellow(l) : kind === "ok" ? accent(l) : kind === "warn" ? yellow(l) : dim(l))),
|
|
10021
|
+
});
|
|
10022
|
+
console.log("");
|
|
10023
|
+
void refreshCliToken(); // the bar's trial line, now that there is a token to describe
|
|
10024
|
+
return r.ok;
|
|
10025
|
+
}
|
|
10026
|
+
|
|
10027
|
+
/** At the first ">>": private mode with no usable token asks for one first (#314). */
|
|
10028
|
+
async function askTokenAtStart() {
|
|
10029
|
+
if (!isClientExpert() || !process.stdin.isTTY) return;
|
|
10030
|
+
const ct = await import("./cli-token.mjs");
|
|
10031
|
+
if (!ct.publicKey()) return;
|
|
10032
|
+
const dir = join(homedir(), ".gonext");
|
|
10033
|
+
const state = await ct.readState(dir);
|
|
10034
|
+
if (!ct.turnNeedsToken({ privateMode: true, token: state.token })) return;
|
|
10035
|
+
await askForCliToken(ct, dir);
|
|
10036
|
+
}
|
|
10037
|
+
|
|
9786
10038
|
async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {}) {
|
|
9787
10039
|
// #238: when the licence is ENFORCED and this account is not entitled, no turn is sent. Decided at
|
|
9788
10040
|
// startup (see licenceBlock) — never re-checked here, so a turn cannot die halfway through.
|
|
@@ -11303,7 +11555,12 @@ async function main() {
|
|
|
11303
11555
|
// The folder's last RAG/context picture, so the bar is populated before the first turn rather
|
|
11304
11556
|
// than reading `rag 0` — which is indistinguishable from "RAG contributed nothing".
|
|
11305
11557
|
lastPromptBreakdown = await loadPromptBreakdown(resolve(process.cwd()));
|
|
11306
|
-
|
|
11558
|
+
// Esc at "Register it…?" / "Do you trust this folder…?" means "not now" — it used to escape
|
|
11559
|
+
// main() as an unhandled PromptCancelled and end gu on a first install (#314, seen live).
|
|
11560
|
+
const wsCancelled = Symbol("cancelled");
|
|
11561
|
+
if ((await askOr(wsCancelled, () => ensureWorkspace())) === wsCancelled) {
|
|
11562
|
+
console.log(GUTTER + dim("Skipped — this folder is not registered; the agent will not touch files here. Run gu again to be asked."));
|
|
11563
|
+
}
|
|
11307
11564
|
// Restore this folder's remembered auto-test + deploy-target so the banner + agent-ask
|
|
11308
11565
|
// reflect them (both survive a fresh terminal in the same project).
|
|
11309
11566
|
sessionTestAuto = await loadSessionTestAuto(resolve(process.cwd()));
|
|
@@ -11984,6 +12241,13 @@ async function main() {
|
|
|
11984
12241
|
// rows it pushes up are real output rather than half-drawn setup.
|
|
11985
12242
|
syncBarPinning();
|
|
11986
12243
|
|
|
12244
|
+
// #314: private mode with no token — the input row asks for one BEFORE the first ">> ".
|
|
12245
|
+
try {
|
|
12246
|
+
await askTokenAtStart();
|
|
12247
|
+
} catch (e) {
|
|
12248
|
+
console.log(GUTTER + dim(`(the gu token question failed: ${e?.message ?? e} — gu token <paste> adds one)`));
|
|
12249
|
+
}
|
|
12250
|
+
|
|
11987
12251
|
// #301: setup's "Load a saved preset" — the picker opens now that the terminal exists. The whole
|
|
11988
12252
|
// /load-presets flow (the list, the plan, the confirmation, the key) is that command's own.
|
|
11989
12253
|
if (openPresetsAtStart) {
|
|
@@ -12616,9 +12880,23 @@ async function main() {
|
|
|
12616
12880
|
});
|
|
12617
12881
|
}
|
|
12618
12882
|
}
|
|
12619
|
-
|
|
12883
|
+
releaseScreenAtExit();
|
|
12620
12884
|
rl.close();
|
|
12621
12885
|
process.exit(0);
|
|
12622
12886
|
}
|
|
12623
12887
|
|
|
12888
|
+
/**
|
|
12889
|
+
* Give the screen back to the shell with its prompt RIGHT UNDER the last output (#314). unpinBar()
|
|
12890
|
+
* parks the caret at the zone's top row, which is where the shell then printed its prompt: seven
|
|
12891
|
+
* blank rows below the conversation on an 80×24 window. The output position is in the save slot,
|
|
12892
|
+
* so go there instead, and clear the zone's rows below it.
|
|
12893
|
+
*/
|
|
12894
|
+
function releaseScreenAtExit() {
|
|
12895
|
+
if (!barPinned()) return;
|
|
12896
|
+
barReserved = 0;
|
|
12897
|
+
const lines = transcript.lines();
|
|
12898
|
+
const midLine = (lines[lines.length - 1] ?? "") !== "";
|
|
12899
|
+
ui("\x1b[r" + OUT_RESTORE + (midLine ? "\r\n" : "\r") + "\x1b[J");
|
|
12900
|
+
}
|
|
12901
|
+
|
|
12624
12902
|
main();
|
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.776",
|
|
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",
|
|
@@ -65,6 +65,7 @@
|
|
|
65
65
|
"credential-recovery.mjs",
|
|
66
66
|
"settings-transfer.mjs",
|
|
67
67
|
"presets-transfer.mjs",
|
|
68
|
+
"preset-editor.mjs",
|
|
68
69
|
"device-login.mjs",
|
|
69
70
|
"daemon-control.mjs",
|
|
70
71
|
"log-policy.mjs",
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PRESETS IN A REAL EDITOR, AND OUT AGAIN AS TEXT (#312).
|
|
3
|
+
*
|
|
4
|
+
* Asked for: "it doesn't allow to input the presets as a text editor, we need to allow with
|
|
5
|
+
* something like sudo nano … and for load-preset should load all as a long text somehow so that
|
|
6
|
+
* I can copy with a copy button or icon".
|
|
7
|
+
*
|
|
8
|
+
* · /save-preset <name> opens the person's own editor ($VISUAL, $EDITOR, then nano, then vi;
|
|
9
|
+
* Notepad on Windows) on a temp file already filled in — the current setup, or the preset
|
|
10
|
+
* being edited — so it is edited rather than typed from nothing. NOT sudo: the preset is the
|
|
11
|
+
* user's own 0600 file; root would make it root-owned and ask for a password for nothing.
|
|
12
|
+
* · The temp file holds a live key: it is created 0600 inside a private 0700 directory and the
|
|
13
|
+
* directory is removed on every path out, a Ctrl+C included.
|
|
14
|
+
* · /load-presets can show a preset as one framed block — the key MASKED on screen (scrollback
|
|
15
|
+
* and logs must not keep it, #248) — and copy it with the real key: as the ready-to-paste
|
|
16
|
+
* `/save-preset <name> {…}` command (paste it into gu on another computer and it is there),
|
|
17
|
+
* or as plain JSON.
|
|
18
|
+
*
|
|
19
|
+
* Pure helpers plus one spawn; the terminal hand-over (unpinning gu's frozen zone, raw mode, the
|
|
20
|
+
* mouse) is the REPL's job, because only the REPL knows what it switched on.
|
|
21
|
+
*/
|
|
22
|
+
import { spawnSync } from "node:child_process";
|
|
23
|
+
import { mkdtempSync, writeFileSync, readFileSync, rmSync, chmodSync } from "node:fs";
|
|
24
|
+
import { tmpdir } from "node:os";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
import { validatePreset } from "./presets-transfer.mjs";
|
|
27
|
+
|
|
28
|
+
const clean = (v) => (typeof v === "string" ? v.trim() : "");
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Which editor to run → { cmd, args }. $VISUAL then $EDITOR (either may carry arguments, e.g.
|
|
32
|
+
* "code --wait"), then the first of nano / vi that exists; Notepad on Windows, which blocks until
|
|
33
|
+
* its window closes. `has(cmd)` says whether a program is on PATH (injected for tests).
|
|
34
|
+
*/
|
|
35
|
+
export function pickEditor({ env = process.env, platform = process.platform, has = onPath } = {}) {
|
|
36
|
+
for (const v of [env.VISUAL, env.EDITOR]) {
|
|
37
|
+
const words = splitCommand(v);
|
|
38
|
+
if (words.length) return { cmd: words[0], args: words.slice(1) };
|
|
39
|
+
}
|
|
40
|
+
if (platform === "win32") return { cmd: "notepad", args: [] };
|
|
41
|
+
for (const cmd of ["nano", "vi"]) if (has(cmd)) return { cmd, args: [] };
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** "code --wait" → ["code", "--wait"]; quotes group ("'/Applications/My Editor' -w"). */
|
|
46
|
+
export function splitCommand(s) {
|
|
47
|
+
const out = [];
|
|
48
|
+
const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
|
|
49
|
+
let m;
|
|
50
|
+
while ((m = re.exec(String(s ?? "")))) out.push(m[1] ?? m[2] ?? m[3]);
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function onPath(cmd) {
|
|
55
|
+
const probe = process.platform === "win32" ? spawnSync("where", [cmd]) : spawnSync("/bin/sh", ["-c", `command -v ${cmd}`]);
|
|
56
|
+
return probe.status === 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The preset as it would be in a preset file, from whatever is set on this account now. The key
|
|
61
|
+
* is never known here (the API keeps it), so it is left empty for the person to fill in.
|
|
62
|
+
*/
|
|
63
|
+
export function presetFromSettings(settings = {}) {
|
|
64
|
+
const rows = Array.isArray(settings.agentCodingBackends) ? settings.agentCodingBackends : [];
|
|
65
|
+
const agentUrl = clean(settings.agentModelUrl);
|
|
66
|
+
const norm = (u) => clean(u).replace(/\/+$/, "").toLowerCase();
|
|
67
|
+
const row = rows.find((r) => agentUrl && norm(r?.url) === norm(agentUrl)) ?? rows.find((r) => r?.enabled !== false) ?? rows[0] ?? {};
|
|
68
|
+
const apiUrl = agentUrl || clean(row.url);
|
|
69
|
+
return {
|
|
70
|
+
apiUrl,
|
|
71
|
+
apiKey: "",
|
|
72
|
+
agentModel: clean(settings.agentModelName),
|
|
73
|
+
codingModel: clean(row.defaultModel),
|
|
74
|
+
embedModel: clean(settings.ragEmbedModel),
|
|
75
|
+
embedUrl: clean(settings.ragEmbedUrl) || apiUrl,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const FIELDS = ["apiUrl", "apiKey", "agentModel", "codingModel", "embedModel", "embedUrl"];
|
|
80
|
+
|
|
81
|
+
/** The text the editor opens with: a few `//` lines saying what to do, then the JSON. */
|
|
82
|
+
export function editorText(name, preset = {}) {
|
|
83
|
+
const body = {};
|
|
84
|
+
for (const f of FIELDS) body[f] = clean(preset[f]);
|
|
85
|
+
return (
|
|
86
|
+
[
|
|
87
|
+
`// gu preset "${name}" — edit the values, then save and quit to store it.`,
|
|
88
|
+
"// Every field is needed except embedUrl (it defaults to apiUrl). Lines starting with // are ignored.",
|
|
89
|
+
"// Quit without saving (or leave it unchanged) to cancel. The file you save CONTAINS your API key.",
|
|
90
|
+
].join("\n") +
|
|
91
|
+
"\n" +
|
|
92
|
+
JSON.stringify(body, null, 2) +
|
|
93
|
+
"\n"
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* What came back from the editor → { ok, preset } | { ok: false, problem }. The // lines are
|
|
99
|
+
* blanked (not removed) so a JSON error's line number is the line the person sees in the editor.
|
|
100
|
+
*/
|
|
101
|
+
export function parseEdited(text) {
|
|
102
|
+
const src = String(text ?? "").replace(/^[ \t]*\/\/.*$/gm, "");
|
|
103
|
+
if (!src.trim()) return { ok: false, empty: true, problem: "the file is empty." };
|
|
104
|
+
try {
|
|
105
|
+
JSON.parse(src);
|
|
106
|
+
} catch (e) {
|
|
107
|
+
const at = /line (\d+) column (\d+)/.exec(String(e?.message ?? ""));
|
|
108
|
+
const where = at ? ` (line ${at[1]}, column ${at[2]})` : "";
|
|
109
|
+
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
|
+
}
|
|
111
|
+
return validatePreset(src);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Open `initial` in `editor` and return what was saved: { ok: true, text } | { ok: false, why }.
|
|
116
|
+
* The temp file is 0600 in a 0700 directory, and the directory is removed whatever happens.
|
|
117
|
+
* `run` is spawnSync with the terminal inherited (injected for tests).
|
|
118
|
+
*/
|
|
119
|
+
export function editText(initial, { editor, run = spawnSync, tmpRoot = tmpdir(), fileName = "gu-preset.json" } = {}) {
|
|
120
|
+
if (!editor) return { ok: false, why: "no editor found — set $EDITOR (e.g. export EDITOR=nano)" };
|
|
121
|
+
const dir = mkdtempSync(join(tmpRoot, "gu-preset-"));
|
|
122
|
+
try {
|
|
123
|
+
chmodSync(dir, 0o700);
|
|
124
|
+
const file = join(dir, fileName);
|
|
125
|
+
writeFileSync(file, initial, { mode: 0o600 });
|
|
126
|
+
const r = run(editor.cmd, [...editor.args, file], { stdio: "inherit" });
|
|
127
|
+
if (r?.error) return { ok: false, why: `${editor.cmd} could not start — ${r.error.message}` };
|
|
128
|
+
if (typeof r?.status === "number" && r.status !== 0) return { ok: false, why: `${editor.cmd} exited with status ${r.status} — nothing saved` };
|
|
129
|
+
return { ok: true, text: readFileSync(file, "utf8") };
|
|
130
|
+
} finally {
|
|
131
|
+
rmSync(dir, { recursive: true, force: true });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** A key as it may appear on screen: its first few and last four characters. */
|
|
136
|
+
export function maskKey(key) {
|
|
137
|
+
const k = clean(key);
|
|
138
|
+
if (!k) return "";
|
|
139
|
+
if (k.length <= 8) return "…" + k.slice(-2);
|
|
140
|
+
const head = k.startsWith("sk-") ? "sk-" : k.slice(0, 3);
|
|
141
|
+
return `${head}…${k.slice(-4)}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const presetBody = (preset) => {
|
|
145
|
+
const o = {};
|
|
146
|
+
for (const f of FIELDS) if (f !== "embedUrl" || clean(preset.embedUrl) !== clean(preset.apiUrl)) o[f] = clean(preset[f]);
|
|
147
|
+
return o;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/** What "copy" puts on the clipboard: the command that recreates the preset — WITH the key. */
|
|
151
|
+
export function presetCopyCommand(name, preset) {
|
|
152
|
+
return `/save-preset ${clean(name)} ${JSON.stringify(presetBody(preset))}`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** …or just the JSON, pretty, WITH the key. */
|
|
156
|
+
export function presetCopyJson(preset) {
|
|
157
|
+
return JSON.stringify(presetBody(preset), null, 2);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The framed block /load-presets prints for "view": the key masked, "⧉ copy" in the top border.
|
|
162
|
+
* Every row fits `width - 1` so none wraps (a wrapped row breaks the click arithmetic, #109).
|
|
163
|
+
* → { rows, button: { from, to } } — the button's column span on row 0, 0-based, gutter included.
|
|
164
|
+
*/
|
|
165
|
+
export function presetBlock(name, preset, { width = 80, gutter = " " } = {}) {
|
|
166
|
+
const shown = { ...presetBody(preset), apiKey: maskKey(preset.apiKey) };
|
|
167
|
+
const inner = JSON.stringify(shown, null, 2).split("\n");
|
|
168
|
+
const room = Math.max(24, width - 1 - gutter.length);
|
|
169
|
+
const innerW = Math.min(room - 4, Math.max(...inner.map((l) => [...l].length), 30));
|
|
170
|
+
const fit = (l) => {
|
|
171
|
+
const cs = [...l];
|
|
172
|
+
return cs.length > innerW ? cs.slice(0, innerW - 1).join("") + "…" : l + " ".repeat(innerW - cs.length);
|
|
173
|
+
};
|
|
174
|
+
const title = `┌ preset "${clean(name)}" `;
|
|
175
|
+
const copy = " ⧉ copy ";
|
|
176
|
+
const dashes = Math.max(1, innerW + 4 - [...title].length - [...copy].length - 1);
|
|
177
|
+
const top = `${title}${"─".repeat(dashes)}${copy}┐`;
|
|
178
|
+
const buttonFrom = gutter.length + [...title].length + dashes;
|
|
179
|
+
return {
|
|
180
|
+
rows: [top, ...inner.map((l) => `│ ${fit(l)} │`), `└${"─".repeat(innerW + 2)}┘`].map((r) => gutter + r),
|
|
181
|
+
button: { from: buttonFrom, to: buttonFrom + [...copy].length },
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The keys the /load-presets picker takes, in the order its hint line names them. */
|
|
186
|
+
export const PICKER_KEYS = { v: "view", c: "copy", j: "json", e: "edit" };
|
|
187
|
+
export const PICKER_HINT = "↑↓ · Enter apply · v view · c copy command · j copy JSON · e edit · Esc back";
|
package/terminal-layout.mjs
CHANGED
|
@@ -2788,3 +2788,27 @@ export function slashMenuWindow(total, sel, top, rows = SLASH_MENU_ROWS) {
|
|
|
2788
2788
|
const w = listWindow(n, sel < 0 ? 0 : sel, sel < 0 ? 0 : top, rows + 2);
|
|
2789
2789
|
return { from: w.from, to: w.to, above: w.above, below: w.below, top: w.top };
|
|
2790
2790
|
}
|
|
2791
|
+
|
|
2792
|
+
/**
|
|
2793
|
+
* A QUESTION'S HINT, drawn UNDER the input (#305: the trial token's "this is a trial — get a free
|
|
2794
|
+
* token for 1 computer, or Pro for more" sits under the token it describes). Word-wrapped so every
|
|
2795
|
+
* row is ONE physical row — a wrapped row corrupts every redraw that counts rows (#109/#132/#138).
|
|
2796
|
+
* A word longer than the row is hard-cut rather than allowed to wrap.
|
|
2797
|
+
*/
|
|
2798
|
+
export function hintRows(lines, { width, gutter = " ", paint = (s) => s } = {}) {
|
|
2799
|
+
const room = Math.max(10, Math.max(20, width || 80) - 1 - visibleLen(gutter));
|
|
2800
|
+
const out = [];
|
|
2801
|
+
for (const line of lines ?? []) {
|
|
2802
|
+
let row = "";
|
|
2803
|
+
for (const word of String(line).split(/\s+/).filter(Boolean)) {
|
|
2804
|
+
const next = row ? `${row} ${word}` : word;
|
|
2805
|
+
if (visibleLen(next) <= room) row = next;
|
|
2806
|
+
else {
|
|
2807
|
+
if (row) out.push(row);
|
|
2808
|
+
row = visibleLen(word) > room ? clipVisible(word, room) : word;
|
|
2809
|
+
}
|
|
2810
|
+
}
|
|
2811
|
+
if (row) out.push(row);
|
|
2812
|
+
}
|
|
2813
|
+
return out.map((r) => gutter + paint(r));
|
|
2814
|
+
}
|
package/terminal-viewport.mjs
CHANGED
|
@@ -40,23 +40,94 @@ export function createTranscript({ max = 20000 } = {}) {
|
|
|
40
40
|
/** @type {string[]} */
|
|
41
41
|
let lines = [""];
|
|
42
42
|
let total = 0; // lines that have scrolled out of the buffer, so line numbers stay meaningful
|
|
43
|
+
// The line being written, for FOLLOW-THE-CURSOR appends (below). Ordinary appends always write
|
|
44
|
+
// at the end and leave it there.
|
|
45
|
+
let cur = 0;
|
|
46
|
+
let saved = 0;
|
|
43
47
|
|
|
44
48
|
const trim = () => {
|
|
45
49
|
if (lines.length <= max) return;
|
|
46
50
|
const drop = lines.length - max;
|
|
47
51
|
lines = lines.slice(drop);
|
|
48
52
|
total += drop;
|
|
53
|
+
cur = Math.max(0, cur - drop);
|
|
54
|
+
saved = Math.max(0, saved - drop);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* FOLLOW THE CURSOR (#314). Before gu's frozen zone takes the screen, everything on it was
|
|
59
|
+
* printed through here — setup's readline prompts, which redraw their line (ESC[1G + the line
|
|
60
|
+
* again) on an edit; hints drawn under a prompt and walked back over (ESC[nA); progress rows.
|
|
61
|
+
* Stripping those moves recorded the same prompt twice and hint rows in the wrong place, so the
|
|
62
|
+
* first repaint from the transcript (the zone changing height) showed a DIFFERENT screen: lines
|
|
63
|
+
* gone from the setup output, the banner drawn twice. Line-level moves are followed instead:
|
|
64
|
+
* up/down, column 1 (= rewrite the line), erase line/below, save/restore. Not used once the
|
|
65
|
+
* zone is up: from then on in-place UI is drawn outside the transcript (ui()), and a relative
|
|
66
|
+
* move in later content refers to rows the transcript never saw.
|
|
67
|
+
*/
|
|
68
|
+
const TOKEN = /\x1b\[([0-9;?]*)([A-Za-z])|\x1b([78])|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\r|\n|\x1b|[^\x1b\r\n]+/g;
|
|
69
|
+
// At column 0 (after \r, \n or ESC[1G): text written now REPLACES the line, the way it
|
|
70
|
+
// overwrites it on screen — but a \r alone erases nothing (readline's Enter is "\r\n").
|
|
71
|
+
let col0 = true;
|
|
72
|
+
const appendFollowing = (raw) => {
|
|
73
|
+
let m;
|
|
74
|
+
TOKEN.lastIndex = 0;
|
|
75
|
+
while ((m = TOKEN.exec(raw))) {
|
|
76
|
+
const [tok, params, final, decsc] = m;
|
|
77
|
+
if (final) {
|
|
78
|
+
const n = Math.max(1, parseInt(params, 10) || 1);
|
|
79
|
+
if (final === "m") lines[cur] += tok; // colour stays
|
|
80
|
+
else if (final === "A") cur = Math.max(0, cur - n);
|
|
81
|
+
else if (final === "B") {
|
|
82
|
+
cur += n;
|
|
83
|
+
while (cur >= lines.length) lines.push("");
|
|
84
|
+
} else if (final === "G" && n <= 1) col0 = true;
|
|
85
|
+
else if (final === "K" && (params === "2" || col0)) lines[cur] = "";
|
|
86
|
+
else if (final === "J" && (params === "" || params === "0")) {
|
|
87
|
+
lines.length = cur + 1;
|
|
88
|
+
if (col0) lines[cur] = "";
|
|
89
|
+
}
|
|
90
|
+
// anything else (column moves, show/hide cursor, modes) changes no line
|
|
91
|
+
} else if (decsc === "7") saved = cur;
|
|
92
|
+
else if (decsc === "8") cur = Math.min(saved, lines.length - 1);
|
|
93
|
+
else if (tok === "\n") {
|
|
94
|
+
cur += 1;
|
|
95
|
+
if (cur >= lines.length) lines.push("");
|
|
96
|
+
col0 = true;
|
|
97
|
+
} else if (tok === "\r") col0 = true;
|
|
98
|
+
else if (tok[0] !== "\x1b") {
|
|
99
|
+
if (col0) lines[cur] = "";
|
|
100
|
+
col0 = false;
|
|
101
|
+
lines[cur] += tok;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
49
104
|
};
|
|
50
105
|
|
|
51
106
|
return {
|
|
52
|
-
|
|
107
|
+
/** `followCursor`: see appendFollowing — only for output printed before the zone is up. */
|
|
108
|
+
append(text, { followCursor = false } = {}) {
|
|
109
|
+
if (followCursor) {
|
|
110
|
+
appendFollowing(String(text ?? ""));
|
|
111
|
+
trim();
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
53
114
|
const s = sanitize(text ?? "");
|
|
54
115
|
if (!s) return;
|
|
116
|
+
cur = lines.length - 1;
|
|
117
|
+
// A \r REWRITES the line only when text follows it: "\r\n" (CRLF output, readline's Enter)
|
|
118
|
+
// ends the line and keeps it — erasing on the \r itself blanked every such line.
|
|
55
119
|
for (const ch of s) {
|
|
56
|
-
if (ch === "\n")
|
|
57
|
-
|
|
58
|
-
|
|
120
|
+
if (ch === "\n") {
|
|
121
|
+
lines.push("");
|
|
122
|
+
col0 = true;
|
|
123
|
+
} else if (ch === "\r") col0 = true;
|
|
124
|
+
else {
|
|
125
|
+
if (col0) lines[lines.length - 1] = "";
|
|
126
|
+
col0 = false;
|
|
127
|
+
lines[lines.length - 1] += ch;
|
|
128
|
+
}
|
|
59
129
|
}
|
|
130
|
+
cur = lines.length - 1;
|
|
60
131
|
trim();
|
|
61
132
|
},
|
|
62
133
|
/** All lines, oldest first. The last one may still be open (no newline yet). */
|
|
@@ -82,6 +153,8 @@ export function createTranscript({ max = 20000 } = {}) {
|
|
|
82
153
|
clear() {
|
|
83
154
|
lines = [""];
|
|
84
155
|
total = 0;
|
|
156
|
+
cur = 0;
|
|
157
|
+
saved = 0;
|
|
85
158
|
},
|
|
86
159
|
};
|
|
87
160
|
}
|