@tiens.nguyen/gu-cli 1.0.775 → 1.0.777
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 +36 -21
- package/client-expert-run.mjs +0 -20
- package/gu-cli.mjs +4 -1
- package/gu-repl.mjs +149 -22
- package/package.json +1 -1
- package/setup-prompts.mjs +2 -21
- 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
|
@@ -195,20 +195,24 @@ export function shortToken(token) {
|
|
|
195
195
|
}
|
|
196
196
|
|
|
197
197
|
/**
|
|
198
|
-
* THE TOKEN QUESTION, WITH THE TRIAL AS ITS DEFAULT (#305,
|
|
199
|
-
* wait for the user to input a token, but show a default token and hint that it is a free trial
|
|
200
|
-
* for 10 days — not silently").
|
|
198
|
+
* THE TOKEN QUESTION, WITH THE TRIAL AS ITS DEFAULT (#305/#76, placed by #314).
|
|
201
199
|
*
|
|
202
200
|
* Input token: gu1.eyJ2IjoxLCJz…Xk9aQw ← typed in: Enter takes it, or delete it
|
|
203
201
|
* This is a free trial token: 10 days on this computer, until 10 Oct 2026. ← yellow, under
|
|
204
202
|
* For a free token for 1 computer, log in at gu.example/account and create one — or buy Pro …
|
|
205
203
|
*
|
|
206
204
|
* The trial is fetched first (it is the same one every time for this computer). Nothing is saved
|
|
207
|
-
* without an answer: the shown default → the trial; anything else is checked as a pasted token
|
|
208
|
-
* nothing at all (the default deleted) → skipped. Asked at the end of first-time setup, and before
|
|
209
|
-
* a private-mode turn whenever there is still no token.
|
|
205
|
+
* without an answer: the shown default → the trial; anything else is checked as a pasted token.
|
|
210
206
|
*
|
|
211
|
-
*
|
|
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".
|
|
212
216
|
* → { ok: true, kind: "trial" | "pasted" } | { ok: false, why: "skipped" | "refused" }
|
|
213
217
|
*/
|
|
214
218
|
export async function promptForToken({
|
|
@@ -220,25 +224,36 @@ export async function promptForToken({
|
|
|
220
224
|
device,
|
|
221
225
|
claim = claimTrial,
|
|
222
226
|
publicKeyB64 = publicKey(),
|
|
227
|
+
required = false,
|
|
228
|
+
maxRounds = 1000, // a bound for tests; a person answers long before this
|
|
223
229
|
}) {
|
|
224
230
|
const accountUrl = `${String(site).replace(/^https?:\/\//, "")}/account`;
|
|
225
231
|
const dev = device === undefined ? await trialDevice().catch(() => null) : device;
|
|
226
|
-
const trial = dev ? await claim({ hostedBase, device: dev }) : { ok: false, why: "no-device" };
|
|
227
|
-
const trialOk = trial.ok && verifyCliToken(trial.token, publicKeyB64).ok;
|
|
228
|
-
|
|
229
|
-
const shown = trialOk ? shortToken(trial.token) : "";
|
|
230
232
|
const getOne = `For a free token for 1 computer, log in at ${accountUrl} and create one — or buy Pro there for more computers.`;
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
:
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
]
|
|
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();
|
|
238
248
|
log("This computer needs a gu token before the agent can start — one time only.", "title");
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
+
}
|
|
242
257
|
if (trialOk && (typed === shown || typed === trial.token)) {
|
|
243
258
|
await writeState(dir, { token: trial.token, lastAttemptAt: new Date().toISOString() });
|
|
244
259
|
log(`✓ free trial token saved — 10 days on this computer, until ${shortDate(trial.endsAt)}.`, "ok");
|
package/client-expert-run.mjs
CHANGED
|
@@ -1153,26 +1153,6 @@ export async function runClientExpertSetup({
|
|
|
1153
1153
|
await saveState(state);
|
|
1154
1154
|
}
|
|
1155
1155
|
|
|
1156
|
-
// THE gu TOKEN, AS THE LAST SETUP STEP (#305): private mode needs one before the first turn, so
|
|
1157
|
-
// it is asked while the person is still answering setup questions — with this computer's free
|
|
1158
|
-
// 10-day trial as the default (Enter), never taken silently. Skipped → asked at the first turn.
|
|
1159
|
-
try {
|
|
1160
|
-
const ct = await import("./cli-token.mjs");
|
|
1161
|
-
const had = await ct.readState(join(homedir(), ".gonext"));
|
|
1162
|
-
if (ct.publicKey() && !(had.token && ct.verifyCliToken(had.token, ct.publicKey()).ok)) {
|
|
1163
|
-
const { DEFAULT_API_BASE } = await import("./device-login.mjs");
|
|
1164
|
-
console.log("");
|
|
1165
|
-
await ct.promptForToken({
|
|
1166
|
-
ask: (label, opts) => ask(" " + label, opts),
|
|
1167
|
-
dir: join(homedir(), ".gonext"),
|
|
1168
|
-
hostedBase: String(process.env.GU_TOKEN_API_BASE ?? "").trim() || DEFAULT_API_BASE,
|
|
1169
|
-
log: (l, kind) => console.log(" " + (kind === "ok" ? cyan(l) : kind === "title" ? bold(l) : kind === "warn" ? yellow(l) : dim(l))),
|
|
1170
|
-
});
|
|
1171
|
-
}
|
|
1172
|
-
} catch (e) {
|
|
1173
|
-
console.log(dim(` (the gu token can be added later: gu token <paste> — ${e?.message ?? e})`));
|
|
1174
|
-
}
|
|
1175
|
-
|
|
1176
1156
|
console.log(
|
|
1177
1157
|
"\n " + cyan(bold("Local mode is set up.")) +
|
|
1178
1158
|
dim("\n Everything — conversations, token counts, workspaces — stays in MongoDB on this") +
|
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
|
@@ -658,11 +658,8 @@ if (!workerKey || !apiBase) {
|
|
|
658
658
|
input: process.stdin,
|
|
659
659
|
output: process.stdout,
|
|
660
660
|
});
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
opts
|
|
664
|
-
? String((await (await import("./setup-prompts.mjs")).askOn(rl0, q, { style: accent, ...opts })) || "")
|
|
665
|
-
: new Promise((res) => rl0.question(accent(q), (a) => res(String(a || ""))));
|
|
661
|
+
const ask0 = (q) =>
|
|
662
|
+
new Promise((res) => rl0.question(accent(q), (a) => res(String(a || ""))));
|
|
666
663
|
let typed = "";
|
|
667
664
|
// Set when Client Expert setup stops on something the user must do first: the run ends
|
|
668
665
|
// after the instructions instead of continuing into questions that assume a paired,
|
|
@@ -973,6 +970,10 @@ function printCommands(prefix = "") {
|
|
|
973
970
|
// bar, the live status block, the slash overlay, the pickers, mode-setting escapes — is
|
|
974
971
|
// written through `ui()`, which marks it so it is neither recorded nor held back.
|
|
975
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;
|
|
976
977
|
let _uiDepth = 0;
|
|
977
978
|
let scrollOffset = 0; // lines the view is scrolled BACK from the bottom; 0 = live
|
|
978
979
|
const scrolledBack = () => scrollOffset > 0;
|
|
@@ -1046,7 +1047,7 @@ const contentWrite = (raw, chunk, enc, cb) => {
|
|
|
1046
1047
|
// Any real output commits a held question first (see pendingEcho above).
|
|
1047
1048
|
if (_uiDepth === 0 && pendingEcho !== null) flushPendingEcho();
|
|
1048
1049
|
if (_uiDepth === 0 && (typeof chunk === "string" || Buffer.isBuffer(chunk))) {
|
|
1049
|
-
transcript.append(String(chunk));
|
|
1050
|
+
transcript.append(String(chunk), { followCursor: !zoneTookOver });
|
|
1050
1051
|
// ALWAYS FOLLOW THE BOTTOM. New output means there is something newer to see, so a view
|
|
1051
1052
|
// that was scrolled back comes home first and the line lands where the user is looking.
|
|
1052
1053
|
// Scrolling up is therefore the ONLY way to leave the live tail — which is exactly when the
|
|
@@ -1154,7 +1155,12 @@ rl._ttyWrite = (str, key) => {
|
|
|
1154
1155
|
? makePasteToken(trimmed)
|
|
1155
1156
|
: trimmed.replace(/\n/g, " "); // a short paste stays literal, on one line
|
|
1156
1157
|
// A paste over a selection REPLACES it, exactly as typing over one does — this runs before
|
|
1157
|
-
// 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
|
+
}
|
|
1158
1164
|
dropSelectedInputText();
|
|
1159
1165
|
// Insert at the caret so a paste mid-sentence lands where the user put it.
|
|
1160
1166
|
const before = rl.line.slice(0, rl.cursor);
|
|
@@ -1235,6 +1241,7 @@ rl._ttyWrite = (str, key) => {
|
|
|
1235
1241
|
// ESC CANCELS A TYPED QUESTION. Below the CPR block deliberately: a probe reply also begins
|
|
1236
1242
|
// with ESC, and testing first would read the terminal's own answer as the user pressing Esc.
|
|
1237
1243
|
if (maybeCancelPrompt(str, key)) return;
|
|
1244
|
+
if (atomicDefaultKey(str, key)) return;
|
|
1238
1245
|
// --- a selection on the input line is EDITABLE, not just copyable (task #177) ----------
|
|
1239
1246
|
// Selecting the line and pressing Backspace is how every text field on the machine clears
|
|
1240
1247
|
// one, and without it the only way to drop a long question is to hold the key down. Handled
|
|
@@ -1956,11 +1963,49 @@ function renderSlashOverlay(sel) {
|
|
|
1956
1963
|
slashNeutralizeHistory();
|
|
1957
1964
|
}
|
|
1958
1965
|
slashHintRows = menu.length;
|
|
1966
|
+
if (barPinned()) {
|
|
1967
|
+
paintSlashMenuOverConversation(menu);
|
|
1968
|
+
return;
|
|
1969
|
+
}
|
|
1959
1970
|
regionUi(
|
|
1960
1971
|
slashOverlaySeq(line, rl.cursor ?? line.length, menu, process.stdout.columns)
|
|
1961
1972
|
);
|
|
1962
1973
|
}
|
|
1963
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
|
+
|
|
1964
2009
|
// Close the overlay: erase the menu below, keep the typed input line, hand drawing back
|
|
1965
2010
|
// to readline. Callers that changed the line to a non-slash value should refresh after.
|
|
1966
2011
|
// Relative moves only, for the same reason as above.
|
|
@@ -1969,9 +2014,12 @@ function closeSlashOverlay() {
|
|
|
1969
2014
|
slashRestoreHistory();
|
|
1970
2015
|
return;
|
|
1971
2016
|
}
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
2017
|
+
if (slashDrawn) clearSlashMenuOverConversation();
|
|
2018
|
+
else {
|
|
2019
|
+
regionUi(
|
|
2020
|
+
slashCloseSeq(rl.line, rl.cursor ?? rl.line.length, process.stdout.columns)
|
|
2021
|
+
);
|
|
2022
|
+
}
|
|
1975
2023
|
slashOverlayActive = false;
|
|
1976
2024
|
slashHintRows = 0;
|
|
1977
2025
|
slashSel = -1;
|
|
@@ -2535,6 +2583,7 @@ const barPinned = () => barReserved > 0;
|
|
|
2535
2583
|
/** Reserve the rows (or re-cut them after a resize) and paint the bar into them. */
|
|
2536
2584
|
function pinBar() {
|
|
2537
2585
|
if (!canPinBar()) return;
|
|
2586
|
+
zoneTookOver = true;
|
|
2538
2587
|
const rows = frozenRowCount();
|
|
2539
2588
|
// Re-cutting keeps the OUTPUT position that is already in the save slot (preserveCaret:false);
|
|
2540
2589
|
// paintBar() below puts the caret back on the input line.
|
|
@@ -4267,12 +4316,35 @@ let askWaiting = false;
|
|
|
4267
4316
|
*/
|
|
4268
4317
|
function maybeCancelPrompt(str, key) {
|
|
4269
4318
|
if (!shouldCancelPrompt(askWaiting, key)) return false;
|
|
4319
|
+
if (askNoCancel) return true; // a question with no way round it (#314): Esc does nothing
|
|
4270
4320
|
rl.line = "";
|
|
4271
4321
|
rl.cursor = 0;
|
|
4272
4322
|
submitSyntheticLine(CANCEL_LINE);
|
|
4273
4323
|
return true;
|
|
4274
4324
|
}
|
|
4275
|
-
|
|
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 } = {}) => {
|
|
4276
4348
|
// Route the question through readline's own prompt so its redraw logic stays
|
|
4277
4349
|
// consistent (a raw stdout write here would re-create the stale-cursor artifact
|
|
4278
4350
|
// that terminal:true mode is designed to avoid). Restored to ">> " afterwards.
|
|
@@ -4340,6 +4412,8 @@ const ask = async (q, { prefill = "", hint = [] } = {}) => {
|
|
|
4340
4412
|
// #305: a default typed in for the person to take (Enter) or delete, and a hint drawn under
|
|
4341
4413
|
// the input (frozenZone) — both only for this question.
|
|
4342
4414
|
askHint = hint;
|
|
4415
|
+
askAtomic = atomic ? String(prefill) : "";
|
|
4416
|
+
askNoCancel = noCancel;
|
|
4343
4417
|
if (prefill) {
|
|
4344
4418
|
rl.line = String(prefill);
|
|
4345
4419
|
rl.cursor = rl.line.length;
|
|
@@ -4362,6 +4436,8 @@ const ask = async (q, { prefill = "", hint = [] } = {}) => {
|
|
|
4362
4436
|
} finally {
|
|
4363
4437
|
askWaiting = false;
|
|
4364
4438
|
askHint = [];
|
|
4439
|
+
askAtomic = "";
|
|
4440
|
+
askNoCancel = false;
|
|
4365
4441
|
inlinePrompt = "";
|
|
4366
4442
|
inputVisible = wasVisible;
|
|
4367
4443
|
paintBar(); // put ">> " back, or the next question wears this one's label
|
|
@@ -4372,12 +4448,16 @@ const ask = async (q, { prefill = "", hint = [] } = {}) => {
|
|
|
4372
4448
|
rl.setPrompt(gutterLines(q));
|
|
4373
4449
|
rl.prompt();
|
|
4374
4450
|
if (prefill) rl.write(String(prefill));
|
|
4451
|
+
askAtomic = atomic ? String(prefill) : "";
|
|
4452
|
+
askNoCancel = noCancel;
|
|
4375
4453
|
let a;
|
|
4376
4454
|
try {
|
|
4377
4455
|
askWaiting = true;
|
|
4378
4456
|
a = await nextLine();
|
|
4379
4457
|
} finally {
|
|
4380
4458
|
askWaiting = false;
|
|
4459
|
+
askAtomic = "";
|
|
4460
|
+
askNoCancel = false;
|
|
4381
4461
|
}
|
|
4382
4462
|
rl.setPrompt(PROMPT);
|
|
4383
4463
|
// Esc here too: the unpinned path is what a short window and a piped stdout get, and a cancel
|
|
@@ -9915,23 +9995,44 @@ async function ensureTokenForTurn() {
|
|
|
9915
9995
|
const dir = join(homedir(), ".gonext");
|
|
9916
9996
|
const state = await ct.readState(dir);
|
|
9917
9997
|
if (!ct.turnNeedsToken({ privateMode: true, token: state.token })) return true;
|
|
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;
|
|
10001
|
+
console.log(GUTTER + dim("Not sent. Run `gu token <paste>` any time, then send your message again.") + "\n");
|
|
10002
|
+
return false;
|
|
10003
|
+
}
|
|
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) {
|
|
9918
10012
|
console.log("");
|
|
9919
|
-
// #305: the question, with this computer's free trial as the default — offered, never taken
|
|
9920
|
-
// silently (the user's rule). Enter takes the trial; a pasted token replaces it.
|
|
9921
10013
|
const { DEFAULT_API_BASE } = await import("./device-login.mjs");
|
|
9922
10014
|
const r = await ct.promptForToken({
|
|
9923
|
-
ask: (label, opts) => ask(
|
|
10015
|
+
ask: (label, opts) => ask(label, opts),
|
|
9924
10016
|
dir,
|
|
10017
|
+
required: true,
|
|
9925
10018
|
hostedBase: String(process.env.GU_TOKEN_API_BASE ?? "").trim() || DEFAULT_API_BASE,
|
|
9926
10019
|
log: (l, kind) =>
|
|
9927
10020
|
console.log(GUTTER + (kind === "title" ? yellow(l) : kind === "ok" ? accent(l) : kind === "warn" ? yellow(l) : dim(l))),
|
|
9928
10021
|
});
|
|
9929
|
-
|
|
9930
|
-
|
|
9931
|
-
|
|
9932
|
-
|
|
9933
|
-
|
|
9934
|
-
|
|
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);
|
|
9935
10036
|
}
|
|
9936
10037
|
|
|
9937
10038
|
async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {}) {
|
|
@@ -11454,7 +11555,12 @@ async function main() {
|
|
|
11454
11555
|
// The folder's last RAG/context picture, so the bar is populated before the first turn rather
|
|
11455
11556
|
// than reading `rag 0` — which is indistinguishable from "RAG contributed nothing".
|
|
11456
11557
|
lastPromptBreakdown = await loadPromptBreakdown(resolve(process.cwd()));
|
|
11457
|
-
|
|
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
|
+
}
|
|
11458
11564
|
// Restore this folder's remembered auto-test + deploy-target so the banner + agent-ask
|
|
11459
11565
|
// reflect them (both survive a fresh terminal in the same project).
|
|
11460
11566
|
sessionTestAuto = await loadSessionTestAuto(resolve(process.cwd()));
|
|
@@ -12135,6 +12241,13 @@ async function main() {
|
|
|
12135
12241
|
// rows it pushes up are real output rather than half-drawn setup.
|
|
12136
12242
|
syncBarPinning();
|
|
12137
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
|
+
|
|
12138
12251
|
// #301: setup's "Load a saved preset" — the picker opens now that the terminal exists. The whole
|
|
12139
12252
|
// /load-presets flow (the list, the plan, the confirmation, the key) is that command's own.
|
|
12140
12253
|
if (openPresetsAtStart) {
|
|
@@ -12767,9 +12880,23 @@ async function main() {
|
|
|
12767
12880
|
});
|
|
12768
12881
|
}
|
|
12769
12882
|
}
|
|
12770
|
-
|
|
12883
|
+
releaseScreenAtExit();
|
|
12771
12884
|
rl.close();
|
|
12772
12885
|
process.exit(0);
|
|
12773
12886
|
}
|
|
12774
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
|
+
|
|
12775
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.777",
|
|
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/setup-prompts.mjs
CHANGED
|
@@ -16,39 +16,23 @@
|
|
|
16
16
|
* the one stretch of the program that runs before it.
|
|
17
17
|
*/
|
|
18
18
|
import { SKIP_ALL, isEscapeKey } from "./model-setup-ui.mjs";
|
|
19
|
-
import { footerDrawSeq, visibleLen, hintRows } from "./terminal-layout.mjs";
|
|
20
19
|
|
|
21
20
|
/**
|
|
22
21
|
* One question on `rl`. Options: `secret` (mask + keep out of history), `esc` (Esc → SKIP_ALL).
|
|
23
|
-
* `style` decorates the label (the accent colour).
|
|
24
|
-
* person to take with Enter or delete (#305); `hint` is lines drawn in yellow UNDER the input,
|
|
25
|
-
* word-wrapped to one physical row each (terminal-layout hintRows), and wiped once answered.
|
|
26
|
-
* Resolves with the answer string, or SKIP_ALL.
|
|
22
|
+
* `style` decorates the label (the accent colour). Resolves with the answer string, or SKIP_ALL.
|
|
27
23
|
*/
|
|
28
|
-
export function askOn(rl, q, { secret = false, esc = false, style = (s) => s,
|
|
24
|
+
export function askOn(rl, q, { secret = false, esc = false, style = (s) => s, input = process.stdin, output = process.stdout } = {}) {
|
|
29
25
|
return new Promise((resolve) => {
|
|
30
26
|
const shown = style(q);
|
|
31
27
|
const ac = new AbortController();
|
|
32
28
|
const origWrite = rl._writeToOutput;
|
|
33
29
|
const origRefresh = rl._refreshLine;
|
|
34
30
|
let done = false;
|
|
35
|
-
const showHint = !secret && hint.length > 0 && output.isTTY;
|
|
36
|
-
// Drawn below the input's LAST row with relative moves (footerDrawSeq), so it survives the
|
|
37
|
-
// terminal scrolling; redrawn after every key because readline clears below on a refresh.
|
|
38
|
-
const drawHint = () => {
|
|
39
|
-
const cols = Math.max(20, output.columns || 80);
|
|
40
|
-
const pos = rl.getCursorPos?.() ?? { rows: 0, cols: 0 };
|
|
41
|
-
const inputRows = Math.floor((visibleLen(shown) + String(rl.line ?? "").length) / cols) + 1;
|
|
42
|
-
const rows = hintRows(hint, { width: cols, gutter: " ", paint: (t) => `\x1b[33m${t}\x1b[0m` });
|
|
43
|
-
output.write(footerDrawSeq({ rows, caretRows: pos.rows, caretCols: pos.cols, inputRows }));
|
|
44
|
-
};
|
|
45
31
|
|
|
46
32
|
const finish = (value) => {
|
|
47
33
|
if (done) return;
|
|
48
34
|
done = true;
|
|
49
35
|
input.removeListener?.("keypress", onKey);
|
|
50
|
-
// Enter left the caret on the first hint row: the hint goes with the question.
|
|
51
|
-
if (showHint && value !== SKIP_ALL) output.write("\r\x1b[J");
|
|
52
36
|
if (secret) {
|
|
53
37
|
rl._writeToOutput = origWrite;
|
|
54
38
|
rl._refreshLine = origRefresh;
|
|
@@ -76,14 +60,11 @@ export function askOn(rl, q, { secret = false, esc = false, style = (s) => s, pr
|
|
|
76
60
|
if (secret && output.isTTY) {
|
|
77
61
|
output.write(`\r\x1b[2K${shown}${"•".repeat(String(rl.line ?? "").length)}`);
|
|
78
62
|
}
|
|
79
|
-
if (showHint) drawHint();
|
|
80
63
|
}
|
|
81
64
|
|
|
82
65
|
input.on?.("keypress", onKey);
|
|
83
66
|
// question() draws the label synchronously — mute only AFTER, or the label is muted too.
|
|
84
67
|
rl.question(shown, { signal: ac.signal }, (a) => finish(String(a ?? "")));
|
|
85
|
-
if (prefill && !secret) rl.write(String(prefill));
|
|
86
|
-
if (showHint) drawHint();
|
|
87
68
|
if (secret) {
|
|
88
69
|
rl._writeToOutput = () => {};
|
|
89
70
|
rl._refreshLine = () => {};
|
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
|
}
|