@tiens.nguyen/gu-cli 1.0.753 → 1.0.754

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.
@@ -6633,6 +6633,92 @@ def _ws_delete_outside_reason(argv, wd, roots=None):
6633
6633
  + (" …" if len(outside) > 3 else "") + ")")
6634
6634
 
6635
6635
 
6636
+ # GU AS A CO-AUTHOR (task #289), the way Claude Code credits Claude: every commit the AGENT makes
6637
+ # ends with a trailer GitHub turns into a second author with gu's avatar. GitHub only does that
6638
+ # when the email belongs to an account — gu-marsic-agent's noreply address always does. Added by
6639
+ # run_command itself (after the risk gate, just before the spawn), not left to the model: a rule
6640
+ # in the prompt is followed most of the time. Only commits gu makes; the user's own are untouched.
6641
+ #
6642
+ # Settings, ~/.gonext/settings.json then <project>/.gonext/settings.json (the project wins):
6643
+ # "attribution": {"commit": true, "pr": true, "name": "gu", "email": "…"}
6644
+ _ATTRIBUTION_DEFAULT = {"commit": True, "pr": True, "name": "gu",
6645
+ "email": "334027377+gu-marsic-agent@users.noreply.github.com"}
6646
+ _PR_FOOTER = "Generated with gu"
6647
+ _GIT_GLOBAL_WITH_VALUE = {"-C", "-c", "--git-dir", "--work-tree", "--namespace", "--exec-path",
6648
+ "--super-prefix", "--config-env"}
6649
+
6650
+
6651
+ def _attribution_settings(wd="", home=None, roots=None):
6652
+ """The attribution settings in force for a command run in `wd`."""
6653
+ out = dict(_ATTRIBUTION_DEFAULT)
6654
+ home = home or os.path.expanduser("~")
6655
+ places = [os.path.join(home, ".gonext", "settings.json")]
6656
+ wd_real = os.path.realpath(wd) if wd else ""
6657
+ for r in (_WS_ROOTS if roots is None else roots):
6658
+ p = os.path.realpath(r.get("path") or "")
6659
+ if p and wd_real and (wd_real == p or wd_real.startswith(p.rstrip(os.sep) + os.sep)):
6660
+ places.append(os.path.join(p, ".gonext", "settings.json"))
6661
+ break
6662
+ for path in places:
6663
+ try:
6664
+ with open(path, encoding="utf-8") as fh:
6665
+ a = (json.load(fh) or {}).get("attribution")
6666
+ except (OSError, ValueError, AttributeError):
6667
+ continue
6668
+ if isinstance(a, dict):
6669
+ out.update({k: v for k, v in a.items() if k in _ATTRIBUTION_DEFAULT})
6670
+ return out
6671
+
6672
+
6673
+ def _git_subcommand(argv):
6674
+ """(index, name) of git's subcommand in `argv`, skipping git's own options — or (-1, "")."""
6675
+ if not argv or os.path.basename(argv[0]).lower() not in ("git", "git.exe"):
6676
+ return -1, ""
6677
+ i = 1
6678
+ while i < len(argv):
6679
+ a = argv[i]
6680
+ if a in _GIT_GLOBAL_WITH_VALUE:
6681
+ i += 2
6682
+ continue
6683
+ if a.startswith("-"):
6684
+ i += 1
6685
+ continue
6686
+ return i, a
6687
+ return -1, ""
6688
+
6689
+
6690
+ def _with_attribution(argv, settings):
6691
+ """`argv` with gu credited, and a note for the log — or (argv, "") when nothing applies.
6692
+
6693
+ git commit → `--trailer "Co-Authored-By: gu <email>"` (git ≥ 2.32; git places it after any
6694
+ other trailers and skips it when the same one is already last). Skipped when the message
6695
+ already names that email (the model wrote it itself). gh pr create → a footer on --body.
6696
+ """
6697
+ argv = list(argv)
6698
+ name = str(settings.get("name") or "").strip()
6699
+ email = str(settings.get("email") or "").strip()
6700
+ i, sub = _git_subcommand(argv)
6701
+ if sub == "commit" and settings.get("commit") and name and email:
6702
+ if any(email.lower() in str(a).lower() for a in argv[i + 1:]):
6703
+ return argv, ""
6704
+ return argv[:i + 1] + ["--trailer", f"Co-Authored-By: {name} <{email}>"] + argv[i + 1:], \
6705
+ f"co-author trailer added ({name})"
6706
+ if (argv[:3] == ["gh", "pr", "create"] or (argv and os.path.basename(argv[0]).lower() == "gh.exe"
6707
+ and argv[1:3] == ["pr", "create"])) and settings.get("pr"):
6708
+ for k, a in enumerate(argv):
6709
+ if a in ("--body", "-b") and k + 1 < len(argv):
6710
+ if _PR_FOOTER not in argv[k + 1]:
6711
+ argv[k + 1] = argv[k + 1].rstrip() + f"\n\n{_PR_FOOTER}"
6712
+ return argv, "PR footer added"
6713
+ return argv, ""
6714
+ if a.startswith("--body="):
6715
+ if _PR_FOOTER not in a:
6716
+ argv[k] = a.rstrip() + f"\n\n{_PR_FOOTER}"
6717
+ return argv, "PR footer added"
6718
+ return argv, ""
6719
+ return argv, ""
6720
+
6721
+
6636
6722
  def _ws_command_risk(command, argv, denyset, wd=""):
6637
6723
  """Classify a command for the run policy. Returns (hard_reason, ask_reason), either
6638
6724
  None when N/A:
@@ -12276,6 +12362,11 @@ def run_agent_chat(cfg):
12276
12362
  os.makedirs(logs_dir, exist_ok=True)
12277
12363
  log_path = os.path.join(logs_dir, f"{_t.strftime('%Y%m%d-%H%M%S')}-{os.getpid()}.log")
12278
12364
  log_fh = open(log_path, "wb")
12365
+ # gu as a co-author on the commits it makes (task #289) — after every check
12366
+ # above, which judged the command as the model wrote it.
12367
+ argv, _attrib_note = _with_attribution(argv, _attribution_settings(wd))
12368
+ if _attrib_note:
12369
+ _log(f"run_command: {_attrib_note}")
12279
12370
  # Scrubbed env: repo scripts must never see worker secrets.
12280
12371
  proc = subprocess.Popen(
12281
12372
  argv, cwd=wd, env=_ws_scrubbed_env(),
package/gu-cli.mjs CHANGED
@@ -111,9 +111,6 @@ gu-cli
111
111
  Usage:
112
112
  gu-cli
113
113
  gu-cli login [--api-base <url>] # browser sign-in → writes ~/.gonext/worker.env
114
- gu-cli full-log on|off|status [account] # administrators: Enable Full Log for an account (asks for
115
- # an admin token from the web admin page first); status
116
- # alone shows this computer's account, no token needed
117
114
  gu-cli token [<token>] # show this computer's gu token, or save one pasted from
118
115
  # the Account page (Client Expert machines)
119
116
  gu-cli set <workerKey> [--api-base <url>]
package/gu-repl.mjs CHANGED
@@ -128,7 +128,7 @@ import { unapprovedProjectServers } from "./mcp-command.mjs";
128
128
  import { readHooks } from "./hooks-config.mjs";
129
129
  import { codingBannerLabel, isOllamaCloudUrl, SECRET_KEY_HINT } from "./coding-key-prompt.mjs";
130
130
  import { codingChoiceTags, coderDisplayName, coderWhere, whereOfActiveCoder } from "./coding-choice-tags.mjs";
131
- import { IMAGE_NEEDS_CLAUDE, clipboardImageToFile, createImageStore, imagePasteKey, imagePathFromPaste, isClaudeCoderUrl, isImagePasteKey, storeImageFile } from "./image-paste.mjs";
131
+ import { IMAGE_NEEDS_CLAUDE, clipboardImageToFile, fitImageNotice, createImageStore, imagePasteKey, imagePathFromPaste, isClaudeCoderUrl, isImagePasteKey, storeImageFile } from "./image-paste.mjs";
132
132
  import {
133
133
  isDoubleClick,
134
134
  clickRun,
@@ -815,7 +815,6 @@ const COMMANDS = [
815
815
  { name: "/load-settings", usage: "/load-settings [name|path]", desc: "read that file back on another machine — picks from the settings files in your home folder, asks before it replaces anything" },
816
816
  { name: "/doctor", desc: "check every configured model ANSWERS — especially the embedder, which a turn only reaches after several steps" },
817
817
  { name: "/tokens", desc: "break down the last prompt sent to the coding model — how much of it was RAG" },
818
- { name: "/full-log", usage: "/full-log [on|off|status] [account]", desc: "administrators: Enable Full Log for an account — asks for an admin token from the web admin page first" },
819
818
  // ONE name, /new (user, #278). /reset and /clear still work (HIDDEN_COMMANDS) for habit and
820
819
  // for Claude Code users — not to be confused with `gu clear` in a shell, which wipes gu's state.
821
820
  { name: "/new", desc: "start a new conversation in this folder — the old one is kept (archived); the folder's settings stay" },
@@ -1849,8 +1848,7 @@ function slashCloseSeq(line, cursor, cols) {
1849
1848
  let editMode = "accept"; // "accept" | "manual"
1850
1849
  const modeLineText = () =>
1851
1850
  white("✎ " + (editMode === "accept" ? "accept edits on" : "manually edit")) +
1852
- dim(" (shift+tab to switch)") +
1853
- (imageNotice ? " " + orange("⚠ " + imageNotice) : "");
1851
+ dim(" (shift+tab to switch)");
1854
1852
  // Token usage for the bar's middle row (task #104): the LAST turn's code-model input +
1855
1853
  // output, then the coder's lifetime output total. Unlike the old per-turn print, this row is
1856
1854
  // ALWAYS on screen — a turn that did no code-model work honestly reads "0", and the lifetime
@@ -1872,6 +1870,9 @@ let lastPromptBreakdown = null;
1872
1870
  * never leads it, so the screen never claims output that has not been produced.
1873
1871
  */
1874
1872
  let shownOutputTokens = 0;
1873
+ // The same for INPUT (#53): this turn's input so far — the bar's "sent" — shown as "↑ N" before
1874
+ // the "↓ N" output figure, so the thinking line reads both directions the way the bar does.
1875
+ let shownInputTokens = 0;
1875
1876
  // Seconds from Enter to the first full prompt reaching the coding model (prompt assembly +
1876
1877
  // retrieval). Shown by /tokens; 0 until a turn has run.
1877
1878
  let lastPromptAssemblySeconds = 0;
@@ -2298,6 +2299,10 @@ const footerRows = () =>
2298
2299
  identity: identityLineText(),
2299
2300
  token: tokenLineText(),
2300
2301
  mode: modeLineText(),
2302
+ // Its own row (#54), sized to the window so the fix it names is never the part cut off.
2303
+ notice: imageNotice
2304
+ ? orange("⚠ " + fitImageNotice(imageNotice, (process.stdout.columns || 80) - visibleLen(GUTTER) - 1))
2305
+ : "",
2301
2306
  });
2302
2307
  // True only while the MAIN loop's ">> " prompt is waiting for input. The bar belongs to that
2303
2308
  // prompt, so it must never appear under an ask()/askSecret() question or during a turn.
@@ -9644,8 +9649,12 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
9644
9649
  // what makes the climb visible at all. Parked (streaming) the block is not rebuilt, so the
9645
9650
  // figure holds rather than jittering against flowing output.
9646
9651
  shownOutputTokens = easeCount(shownOutputTokens, latestOutputTokens);
9652
+ shownInputTokens = easeCount(shownInputTokens, latestCodeTokens);
9653
+ // ↑ input (this turn's "sent", every request so far — cached input included: the model read
9654
+ // it) then ↓ output (#53). Each absent until known, never a "0" that claims a measurement.
9647
9655
  const outLabel =
9648
- shownOutputTokens > 0 ? dim(" · ↓ ") + accent(fmtTokens(shownOutputTokens)) : "";
9656
+ (shownInputTokens > 0 ? dim(" · ↑ ") + accent(fmtTokens(shownInputTokens)) : "") +
9657
+ (shownOutputTokens > 0 ? dim(" · ↓ ") + accent(fmtTokens(shownOutputTokens)) : "");
9649
9658
  // Task #96: the thought line is "clickable" only when the fuller thought has MORE text than
9650
9659
  // fits on line 1 (a live command / bare "Thinking" / a thought that already fits has nothing
9651
9660
  // to reveal). Line 1 (what's happening now): a live command > the model's current Thought >
@@ -11712,7 +11721,7 @@ async function main() {
11712
11721
  console.log(GUTTER + dim(" Ctrl+C stops watching (it keeps running) · its answer is printed here, not added to this chat\n"));
11713
11722
  let stoppedWatching = false; // detached rather than watched to the end — see below
11714
11723
  try {
11715
- const { text: answer, shown: streamed, elapsedMs, turnOutputTokens, detached } =
11724
+ const { text: answer, shown: streamed, elapsedMs, turnOutputTokens, detached, inputTokens } =
11716
11725
  await runAgentTurn(history, { attachJobId: jobId });
11717
11726
  stoppedWatching = Boolean(detached);
11718
11727
  if (answer && answer.startsWith(TURN_FAILED_PREFIX)) {
@@ -11725,6 +11734,7 @@ async function main() {
11725
11734
  const block = answerBlock({
11726
11735
  elapsedMs, answer: plain, worked: (turnOutputTokens ?? 0) > 0,
11727
11736
  gutter: GUTTER, dim, render: renderAnswer,
11737
+ inputLabel: inputTokens > 0 ? fmtTokens(inputTokens) : "",
11728
11738
  });
11729
11739
  console.log(`${LINE_SPACING}${block.text}\n`);
11730
11740
  registerCopyBlock(block.rows, plain, 1);
@@ -11858,6 +11868,7 @@ async function main() {
11858
11868
  // two must come from one place, and for the empty line under "thought for …".
11859
11869
  const block = answerBlock({
11860
11870
  elapsedMs, answer: plain, worked, gutter: GUTTER, dim, render: renderAnswer,
11871
+ inputLabel: inputTokens > 0 ? fmtTokens(inputTokens) : "",
11861
11872
  });
11862
11873
  console.log(`${LINE_SPACING}${block.text}\n`);
11863
11874
  // Register the SOURCE, not the rendering (task #158): renderAnswer hard-wraps the
package/image-paste.mjs CHANGED
@@ -75,6 +75,19 @@ export function isClaudeCoderUrl(url) {
75
75
 
76
76
  export const IMAGE_NEEDS_CLAUDE =
77
77
  "image paste needs a Claude coding model — pick one with /model";
78
+ /** The same, for a window too narrow for the full sentence: the fix (/model) must survive. */
79
+ export const IMAGE_NEEDS_CLAUDE_SHORT = "images need a Claude coder — /model";
80
+
81
+ /**
82
+ * The notice as it fits `cols` columns (#54): the full sentence, else its short form, else the
83
+ * short form clipped by the caller. Only the Claude warning has a short form; other notices
84
+ * ("no image on the clipboard…") are clipped as they are.
85
+ */
86
+ export function fitImageNotice(text, cols) {
87
+ const t = String(text ?? "");
88
+ if (t === IMAGE_NEEDS_CLAUDE && [...t].length + 2 > cols) return IMAGE_NEEDS_CLAUDE_SHORT;
89
+ return t;
90
+ }
78
91
 
79
92
  /** The key to name in hints on this platform (both work everywhere). */
80
93
  export const imagePasteKey = (platform = process.platform) => (platform === "win32" ? "Alt+V" : "Ctrl+V");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.753",
3
+ "version": "1.0.754",
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",
@@ -475,6 +475,7 @@ export function buildFooterRows({
475
475
  identity = "",
476
476
  token = "",
477
477
  mode = "",
478
+ notice = "",
478
479
  }) {
479
480
  const w = Math.max(20, width || 80);
480
481
  return [
@@ -483,6 +484,10 @@ export function buildFooterRows({
483
484
  ...(identity ? [clipVisible(gutter + identity, w - 1)] : []),
484
485
  clipVisible(gutter + token, w - 1),
485
486
  clipVisible(gutter + mode, w - 1),
487
+ // A passing notice (the image-paste warning, #54) gets its OWN row, under the mode row — it
488
+ // used to be appended to that row and was clipped at the right edge ("…pick one with /mo").
489
+ // Present only while it shows; paintBar re-cuts the frozen zone when the count changes.
490
+ ...(notice ? [clipVisible(gutter + notice, w - 1)] : []),
486
491
  ];
487
492
  }
488
493
 
@@ -1691,13 +1696,17 @@ export function answerBlock({
1691
1696
  dim = (x) => x,
1692
1697
  render = (x) => x,
1693
1698
  doneAt = Date.now(),
1699
+ inputLabel = "",
1694
1700
  } = {}) {
1695
1701
  const rendered = render(worked ? `recap: ${answer}` : answer);
1696
1702
  const clock = formatClockTime(doneAt);
1697
1703
  // The stamp is DROPPED, not defaulted, when the time is unusable — "done Invalid Date" beside
1698
- // a correct duration is worse than no stamp at all.
1704
+ // a correct duration is worse than no stamp at all. The same for the input figure (#53): the
1705
+ // turn's total INPUT tokens, "(↑ 38.2k)", only when the backend reported them.
1699
1706
  const foot =
1700
- gutter + dim(`thought for ${formatThinkTime(elapsedMs)}${clock ? ` · done ${clock}` : ""}`);
1707
+ gutter +
1708
+ dim(`thought for ${formatThinkTime(elapsedMs)}${clock ? ` · done ${clock}` : ""}` +
1709
+ (inputLabel ? ` (↑ ${inputLabel})` : ""));
1701
1710
  const text = `${rendered}\n\n${foot}`;
1702
1711
  return { text, rows: text.split("\n").length };
1703
1712
  }
@@ -2079,6 +2088,8 @@ export const HIDDEN_COMMANDS = [
2079
2088
  "/hover", // in-turn mouse capture — behaviour unchanged, just hidden
2080
2089
  "/reset", // the old name of /new (#278) — still works, one name is offered
2081
2090
  "/clear", // Claude Code's name for it — still works, likewise unlisted
2091
+ "/full-log", // administrators only (#52): needs an ADMIN token from the web admin
2092
+ // page — typing it still works, and that token check is the gate
2082
2093
  "/reset-output-token-global", // pending a rename to /reset-global-token that also resets
2083
2094
  // the INPUT total — which does not exist yet as a counter
2084
2095
  ];