@tiens.nguyen/gu-cli 1.0.895 → 1.0.896
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/README.md +1 -1
- package/cli-token.mjs +8 -3
- package/gonext_agent_chat.py +12 -5
- package/gu-repl.mjs +44 -8
- package/package.json +1 -1
- package/terminal-layout.mjs +62 -0
package/README.md
CHANGED
|
@@ -188,7 +188,7 @@ gu fix-path | fix-powershell repair PATH / remove PowerShell's `gu` alias
|
|
|
188
188
|
| --- | --- | --- |
|
|
189
189
|
| **Free trial** | 30 days, from the first run | nothing: the token is created for you |
|
|
190
190
|
| **Free plan** | 3 more months on that computer | a free account at [gucli.com](https://gucli.com) |
|
|
191
|
-
| **Pro** | **$4 a month** or **$40 a year** (+ VAT where it applies) | hosted models (no model server to run),
|
|
191
|
+
| **Pro** | **$4 a month** or **$40 a year** (+ VAT where it applies) | hosted models (no model server to run), any number of computers, provider budgets |
|
|
192
192
|
|
|
193
193
|
On Pro, the hosted models and the hosted API run on gu's servers, so the prompts and code you
|
|
194
194
|
send through them go there. A free install keeps everything on your computer.
|
package/cli-token.mjs
CHANGED
|
@@ -622,7 +622,8 @@ export function barNotice({ token, publicKeyB64, mode, now = new Date(), attempt
|
|
|
622
622
|
// SHORT AND CALM (#292: "make it simpler … seems so serious"): the fact, then the one thing to
|
|
623
623
|
// do. `urgent` is what the bar colours by — only these two need the user to act.
|
|
624
624
|
if (ended) return { kind: "plan-ended", urgent: true, text: `Pro ended ${shortDate(p.paidUntil)} · renew at ${pricingUrl}` };
|
|
625
|
-
|
|
625
|
+
// Pro has no computer limit (GH #371): an over-limit line is for the other plans only.
|
|
626
|
+
if (p.plan !== "pro" && Number.isInteger(p.activeDevices) && p.activeDevices > p.deviceLimit) {
|
|
626
627
|
return {
|
|
627
628
|
kind: "over-limit",
|
|
628
629
|
urgent: true,
|
|
@@ -728,7 +729,10 @@ export function tokenSummary(payload, now = new Date()) {
|
|
|
728
729
|
}
|
|
729
730
|
if (Number.isInteger(payload.activeDevices)) {
|
|
730
731
|
const n = payload.activeDevices;
|
|
731
|
-
|
|
732
|
+
const paidPro = payload.plan === "pro" && payload.paidUntil && Date.parse(payload.paidUntil) > now.getTime();
|
|
733
|
+
lines.push(paidPro
|
|
734
|
+
? `Used on ${computers(n)} in the last 30 days — Pro has no computer limit.`
|
|
735
|
+
: `Used on ${computers(n)} in the last 30 days — the ${planName(payload.plan)} allows ${payload.deviceLimit}.`);
|
|
732
736
|
}
|
|
733
737
|
lines.push(`Issued ${shortDate(payload.issuedAt)}.`);
|
|
734
738
|
return lines;
|
|
@@ -827,7 +831,8 @@ export function enforcementDecision({ token, publicKeyB64, now = new Date(), sit
|
|
|
827
831
|
);
|
|
828
832
|
}
|
|
829
833
|
}
|
|
830
|
-
|
|
834
|
+
// PRO HAS NO COMPUTER LIMIT (GH #371) — not even with a token from a server that still sends a count.
|
|
835
|
+
if (!paid && Number.isInteger(p.activeDevices) && p.activeDevices > p.deviceLimit) {
|
|
831
836
|
return {
|
|
832
837
|
enforced: true,
|
|
833
838
|
allowed: false,
|
package/gonext_agent_chat.py
CHANGED
|
@@ -6569,17 +6569,24 @@ def _rg_binary(env=None):
|
|
|
6569
6569
|
"""The ripgrep to use, or "" for none. GU_RG=0 turns it off; GU_RG=<path> picks one; then PATH,
|
|
6570
6570
|
then gu's own copy in ~/.gonext/bin."""
|
|
6571
6571
|
import os
|
|
6572
|
-
import shutil
|
|
6573
6572
|
env = os.environ if env is None else env
|
|
6574
6573
|
want = (env.get("GU_RG") or "").strip()
|
|
6575
6574
|
if want.lower() in ("0", "off", "false", "no"):
|
|
6576
6575
|
return ""
|
|
6577
6576
|
if want:
|
|
6578
6577
|
return want if os.path.isfile(want) and os.access(want, os.X_OK) else ""
|
|
6579
|
-
|
|
6580
|
-
|
|
6581
|
-
|
|
6582
|
-
|
|
6578
|
+
# PATH by hand, not shutil.which: on Windows which() looks in the CURRENT folder first, and
|
|
6579
|
+
# gu runs inside the user's project — a repo that ships its own rg.exe would be executed.
|
|
6580
|
+
# Relative PATH entries ("", ".") are skipped for the same reason.
|
|
6581
|
+
exe = "rg.exe" if os.name == "nt" else "rg"
|
|
6582
|
+
for d in (env.get("PATH") or "").split(os.pathsep):
|
|
6583
|
+
d = d.strip().strip('"')
|
|
6584
|
+
if not d or not os.path.isabs(d):
|
|
6585
|
+
continue
|
|
6586
|
+
cand = os.path.join(d, exe)
|
|
6587
|
+
if os.path.isfile(cand) and os.access(cand, os.X_OK):
|
|
6588
|
+
return cand
|
|
6589
|
+
own = os.path.join(os.path.expanduser("~"), ".gonext", "bin", exe)
|
|
6583
6590
|
return own if os.path.isfile(own) and os.access(own, os.X_OK) else ""
|
|
6584
6591
|
|
|
6585
6592
|
|
package/gu-repl.mjs
CHANGED
|
@@ -46,6 +46,7 @@ import dotenv from "dotenv";
|
|
|
46
46
|
import {
|
|
47
47
|
clipVisible,
|
|
48
48
|
createMouseKeyWindow,
|
|
49
|
+
createTurnTypingDetector,
|
|
49
50
|
promptBreakdownRows,
|
|
50
51
|
promptTotal,
|
|
51
52
|
resumeTask,
|
|
@@ -3236,8 +3237,8 @@ function frozenZone() {
|
|
|
3236
3237
|
// #258: typing during a turn is not sent — the row says so (an ask() label still comes first).
|
|
3237
3238
|
prompt: secretInput && secretPrompt
|
|
3238
3239
|
? accent(secretPrompt)
|
|
3239
|
-
:
|
|
3240
|
-
? yellow(
|
|
3240
|
+
: turnRowNotice(width) && !inlinePrompt
|
|
3241
|
+
? yellow(turnRowNotice(width))
|
|
3241
3242
|
: inlinePrompt ? accent(inlinePrompt) : PROMPT,
|
|
3242
3243
|
// WHILE A SECRET IS OPEN, THIS ROW IS THE PROMPT — "kimi-k3 API key: ••••" rather than
|
|
3243
3244
|
// ">> ••••". Same row, same masking; what changes is that it now says what it wants. The
|
|
@@ -3246,8 +3247,8 @@ function frozenZone() {
|
|
|
3246
3247
|
promptCols:
|
|
3247
3248
|
secretInput && secretPrompt
|
|
3248
3249
|
? visibleLen(secretPrompt)
|
|
3249
|
-
:
|
|
3250
|
-
? visibleLen(
|
|
3250
|
+
: turnRowNotice(width) && !inlinePrompt
|
|
3251
|
+
? visibleLen(turnRowNotice(width))
|
|
3251
3252
|
: inlinePrompt
|
|
3252
3253
|
? visibleLen(inlinePrompt)
|
|
3253
3254
|
: PROMPT_COLS,
|
|
@@ -4742,7 +4743,11 @@ if (process.stdin.isTTY) {
|
|
|
4742
4743
|
// are part of a payload readline never sees. Without this, every pasted character also
|
|
4743
4744
|
// re-rendered the slash overlay (the line still starts with "/" from before the paste) and
|
|
4744
4745
|
// re-walked the paste store. Same for a mouse report's characters, which are not keys.
|
|
4745
|
-
if (pasteBuffer !== null
|
|
4746
|
+
if (pasteBuffer !== null) return;
|
|
4747
|
+
if (inMouseKeyWindow()) {
|
|
4748
|
+
turnTyping.reset(); // a held "[" was the start of that report (GH #382)
|
|
4749
|
+
return;
|
|
4750
|
+
}
|
|
4746
4751
|
// Issue #120: compacting → every key is locked (Ctrl+C still arrives as SIGINT).
|
|
4747
4752
|
if (compactLocked()) {
|
|
4748
4753
|
rl.line = "";
|
|
@@ -4789,8 +4794,9 @@ if (process.stdin.isTTY) {
|
|
|
4789
4794
|
if (key && key.name === "tab" && key.shift) switchEditMode();
|
|
4790
4795
|
// A printable key (or Enter after one) means the user is typing a question that will not
|
|
4791
4796
|
// be sent — say so in the input row now, not after they have typed the whole thing (#258).
|
|
4792
|
-
|
|
4793
|
-
|
|
4797
|
+
// Not every printable key is typing: a mouse report or cursor reply whose ESC came alone
|
|
4798
|
+
// arrives as "[", "^[[", "[12;5R" (Windows, GH #382) — the detector sits those out.
|
|
4799
|
+
if (turnTyping.feed(_ch, key) && !typedDuringTurn) {
|
|
4794
4800
|
typedDuringTurn = true;
|
|
4795
4801
|
if (barPinned()) paintBar();
|
|
4796
4802
|
}
|
|
@@ -8869,8 +8875,13 @@ async function refreshLogsSize() {
|
|
|
8869
8875
|
// What is collected and how it is sent is support-bundle.mjs; this is the terminal half only.
|
|
8870
8876
|
let supportBusy = false;
|
|
8871
8877
|
async function runSupport() {
|
|
8878
|
+
// DURING A TURN (GH #382): it used to print one dim line into the turn's output — easy to
|
|
8879
|
+
// miss, then nothing happened, and a stuck turn is exactly when someone wants support. Now it
|
|
8880
|
+
// is QUEUED: the input row says so (with the way out, Ctrl-C) and it opens when the turn ends.
|
|
8872
8881
|
if (following) {
|
|
8873
|
-
|
|
8882
|
+
supportAfterTurn = true;
|
|
8883
|
+
if (barPinned()) paintBar();
|
|
8884
|
+
else console.log("\n" + GUTTER + yellow(SUPPORT_AFTER_TURN_NOTICE.trim()));
|
|
8874
8885
|
return;
|
|
8875
8886
|
}
|
|
8876
8887
|
if (supportBusy || listPickerActive || approvalActive || askWaiting) return;
|
|
@@ -11148,11 +11159,28 @@ let following = false;
|
|
|
11148
11159
|
// typed while the previous one was still queued simply vanished. Now the input row says so the
|
|
11149
11160
|
// moment a key lands, and keeps saying it until the turn ends. Nothing is queued or sent.
|
|
11150
11161
|
let typedDuringTurn = false;
|
|
11162
|
+
const turnTyping = createTurnTypingDetector(); // what counts as typing during a turn (GH #382)
|
|
11151
11163
|
const TYPED_DURING_TURN_NOTICE = "(a turn is running — typing is not sent; type it again when it finishes) ";
|
|
11152
11164
|
const TYPED_DURING_TURN_SHORT = "(turn running — not sent) ";
|
|
11165
|
+
// "? support" clicked during a turn (GH #382): it opens when the turn ends; Ctrl-C ends it now.
|
|
11166
|
+
let supportAfterTurn = false;
|
|
11167
|
+
const SUPPORT_AFTER_TURN_NOTICE = "(support opens when this turn ends — Ctrl-C stops the turn now) ";
|
|
11168
|
+
const SUPPORT_AFTER_TURN_SHORT = "(support after this turn — Ctrl-C) ";
|
|
11169
|
+
// After the first Ctrl-C the turn is stopping; a worker that never answers needs a second one.
|
|
11170
|
+
const SUPPORT_AFTER_STOP_NOTICE = "(support opens when this turn ends — Ctrl-C again to stop waiting) ";
|
|
11171
|
+
const SUPPORT_AFTER_STOP_SHORT = "(support after this turn — Ctrl-C again) ";
|
|
11153
11172
|
/** The notice that fits: the input row must never wrap because of its label (#109/#132). */
|
|
11154
11173
|
const typedNotice = (width) =>
|
|
11155
11174
|
visibleLen(TYPED_DURING_TURN_NOTICE) + 12 <= width ? TYPED_DURING_TURN_NOTICE : TYPED_DURING_TURN_SHORT;
|
|
11175
|
+
/** What the input row says during a turn instead of ">>", or "" — queued support comes first. */
|
|
11176
|
+
const turnRowNotice = (width) =>
|
|
11177
|
+
!following
|
|
11178
|
+
? ""
|
|
11179
|
+
: supportAfterTurn
|
|
11180
|
+
? cancelRequested
|
|
11181
|
+
? visibleLen(SUPPORT_AFTER_STOP_NOTICE) + 12 <= width ? SUPPORT_AFTER_STOP_NOTICE : SUPPORT_AFTER_STOP_SHORT
|
|
11182
|
+
: visibleLen(SUPPORT_AFTER_TURN_NOTICE) + 12 <= width ? SUPPORT_AFTER_TURN_NOTICE : SUPPORT_AFTER_TURN_SHORT
|
|
11183
|
+
: typedDuringTurn ? typedNotice(width) : "";
|
|
11156
11184
|
// Is the turn we are following one this terminal STARTED, or another terminal's that we merely
|
|
11157
11185
|
// attached to (task #211)? `attached` is local to runAgentTurn, and onInterrupt — which lives in
|
|
11158
11186
|
// main() — had no way to ask. So Ctrl+C while watching somebody else's agent took the "cancel my
|
|
@@ -12546,6 +12574,7 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
12546
12574
|
following = true;
|
|
12547
12575
|
followingAttached = attached;
|
|
12548
12576
|
typedDuringTurn = false;
|
|
12577
|
+
turnTyping.reset();
|
|
12549
12578
|
compactSeenPasses = 0; // issue #120: python counts passes per turn
|
|
12550
12579
|
followAborted = false;
|
|
12551
12580
|
currentJobId = jobId;
|
|
@@ -13901,6 +13930,7 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
13901
13930
|
following = false;
|
|
13902
13931
|
followingAttached = false;
|
|
13903
13932
|
typedDuringTurn = false;
|
|
13933
|
+
turnTyping.reset();
|
|
13904
13934
|
endCompactLock(); // issue #120: never leave the terminal locked after a turn
|
|
13905
13935
|
currentJobId = null;
|
|
13906
13936
|
// Drop anything typed (but not submitted) during the turn — echo was muted, so it's
|
|
@@ -14875,6 +14905,7 @@ async function main() {
|
|
|
14875
14905
|
// not just detach. The worker sees the "cancelled" status and kills the Python
|
|
14876
14906
|
// agent; the poll loop then returns cleanly with the "(cancelled)" note.
|
|
14877
14907
|
cancelRequested = true;
|
|
14908
|
+
if (supportAfterTurn && barPinned()) paintBar(); // its row now says "Ctrl-C again" (GH #382)
|
|
14878
14909
|
const jobId = currentJobId;
|
|
14879
14910
|
// #307: while the question is still held (nothing printed yet), nothing goes to the
|
|
14880
14911
|
// transcript either — a quick change of mind leaves no trace.
|
|
@@ -14981,6 +15012,11 @@ async function main() {
|
|
|
14981
15012
|
// the turn ends; a queued line carries no Enter keypress, so its re-prompt overwrites
|
|
14982
15013
|
// in place (\r + refresh) rather than stacking ">> >> >> " on one row.
|
|
14983
15014
|
let lineRaw;
|
|
15015
|
+
// "? support" was clicked during the turn that just ended (GH #382) — however it ended.
|
|
15016
|
+
if (supportAfterTurn) {
|
|
15017
|
+
supportAfterTurn = false;
|
|
15018
|
+
_lineQueue.unshift("/support");
|
|
15019
|
+
}
|
|
14984
15020
|
mainPromptWaiting = true; // a finished background shell may start a recap turn now (#362)
|
|
14985
15021
|
for (;;) {
|
|
14986
15022
|
lineRaw = await nextLine();
|
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.896",
|
|
4
4
|
"description": "gu: the coding agent in your terminal. It reads your repo, makes the change and runs the tests, with your own models (Ollama or any OpenAI-compatible provider). Free installs run entirely on your machine. macOS, Linux and Windows.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
package/terminal-layout.mjs
CHANGED
|
@@ -434,6 +434,68 @@ export function promptBreakdownRows(b) {
|
|
|
434
434
|
}));
|
|
435
435
|
}
|
|
436
436
|
|
|
437
|
+
/**
|
|
438
|
+
* DID THE USER TYPE DURING THE TURN? (GH #382) — for the "(a turn is running — typing is not
|
|
439
|
+
* sent …)" notice. A printable key used to be enough, but the terminal also sends text nobody
|
|
440
|
+
* typed: a mouse report or a cursor-position reply already on its way when Enter started the
|
|
441
|
+
* turn. On Windows its ESC often comes alone (node gives it up after 500ms) and the rest arrives
|
|
442
|
+
* as ordinary characters — "[", "^[[", "[12;5R" — which showed the notice with no key pressed.
|
|
443
|
+
*
|
|
444
|
+
* A lone Esc, "[" or "^" starts a held run. It stays a reply while the characters fit a mouse
|
|
445
|
+
* report or cursor reply ("[", "<", "^", digits, ";") and ends silently on its "M", "m" or "R".
|
|
446
|
+
* Anything else means it was typing after all: `feed` → true. Enter after a held "[" too.
|
|
447
|
+
* The joined mouse shape never gets here (createMouseKeyWindow); the caller calls `reset()` for
|
|
448
|
+
* the keys that window takes, which includes the rest of a split report after its "[".
|
|
449
|
+
*/
|
|
450
|
+
export function createTurnTypingDetector({ maxRun = 32 } = {}) {
|
|
451
|
+
let active = false;
|
|
452
|
+
let held = "";
|
|
453
|
+
const reset = () => {
|
|
454
|
+
active = false;
|
|
455
|
+
held = "";
|
|
456
|
+
};
|
|
457
|
+
return {
|
|
458
|
+
/** → true when this key shows the user typed. Feed every key of the turn, in order. */
|
|
459
|
+
feed(ch, key) {
|
|
460
|
+
if (key?.name === "escape") {
|
|
461
|
+
active = true;
|
|
462
|
+
held = "";
|
|
463
|
+
return false;
|
|
464
|
+
}
|
|
465
|
+
if (key && (key.name === "return" || key.name === "enter")) {
|
|
466
|
+
const typed = active && held !== "";
|
|
467
|
+
reset();
|
|
468
|
+
return typed;
|
|
469
|
+
}
|
|
470
|
+
const printable =
|
|
471
|
+
typeof ch === "string" && ch.length > 0 && !/[\x00-\x1f\x7f]/.test(ch) && !key?.ctrl && !key?.meta;
|
|
472
|
+
if (!printable) {
|
|
473
|
+
reset();
|
|
474
|
+
return false;
|
|
475
|
+
}
|
|
476
|
+
if (!active) {
|
|
477
|
+
if (ch === "[" || ch === "^") {
|
|
478
|
+
active = true;
|
|
479
|
+
held = ch;
|
|
480
|
+
return false;
|
|
481
|
+
}
|
|
482
|
+
return true;
|
|
483
|
+
}
|
|
484
|
+
if (/^[MmR]$/.test(ch) && held.includes("[")) {
|
|
485
|
+
reset();
|
|
486
|
+
return false;
|
|
487
|
+
}
|
|
488
|
+
if (/^[[<^\d;]$/.test(ch) && held.length < maxRun && (held !== "" || ch === "[" || ch === "^")) {
|
|
489
|
+
held += ch;
|
|
490
|
+
return false;
|
|
491
|
+
}
|
|
492
|
+
reset();
|
|
493
|
+
return true;
|
|
494
|
+
},
|
|
495
|
+
reset,
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
437
499
|
/**
|
|
438
500
|
* A MOUSE REPORT IS NOT TYPING — the window in which keypress events belong to the mouse.
|
|
439
501
|
*
|