@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 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), up to 5 computers, provider budgets |
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
- if (Number.isInteger(p.activeDevices) && p.activeDevices > p.deviceLimit) {
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
- lines.push(`Used on ${computers(n)} in the last 30 days — the ${planName(payload.plan)} allows ${payload.deviceLimit}.`);
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
- if (Number.isInteger(p.activeDevices) && p.activeDevices > p.deviceLimit) {
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,
@@ -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
- found = shutil.which("rg", path=env.get("PATH"))
6580
- if found:
6581
- return found
6582
- own = os.path.join(os.path.expanduser("~"), ".gonext", "bin", "rg.exe" if os.name == "nt" else "rg")
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
- : following && typedDuringTurn && !inlinePrompt
3240
- ? yellow(typedNotice(width))
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
- : following && typedDuringTurn && !inlinePrompt
3250
- ? visibleLen(typedNotice(width))
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 || inMouseKeyWindow()) return;
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
- const printable = typeof _ch === "string" && _ch.length > 0 && !/[\x00-\x1f\x7f]/.test(_ch) && !key?.ctrl && !key?.meta;
4793
- if (printable && !typedDuringTurn) {
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
- console.log("\n" + GUTTER + dim("A turn is running - /support is available when it finishes (Ctrl-C stops it)."));
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.895",
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",
@@ -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
  *