@tiens.nguyen/gu-cli 1.0.752 → 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:
@@ -7125,21 +7211,69 @@ def _take_image_markers(messages, images_dir=None):
7125
7211
  return found
7126
7212
 
7127
7213
 
7214
+ # WHAT CLAUDE WILL TAKE (task #285). smolagents re-encodes every image as PNG, base64, before
7215
+ # sending (smolagents.utils.encode_image_base64) — so a 4 MB JPEG photo can become a PNG over the
7216
+ # 5 MB per-image limit, and then Claude rejects the WHOLE request, not just the image. Claude
7217
+ # also scales anything with a long edge over 1568 px down to that (and rejects > 8000 px), so
7218
+ # shrinking first sends the same picture Claude would see, in fewer bytes.
7219
+ _IMAGE_MAX_EDGE = 1568
7220
+ _IMAGE_MAX_B64 = 5 * 1024 * 1024 # measured on the base64 string — the stricter reading
7221
+
7222
+
7223
+ def _png_b64_len(im) -> int:
7224
+ import io
7225
+ buf = io.BytesIO()
7226
+ im.save(buf, format="PNG")
7227
+ return (len(buf.getvalue()) + 2) // 3 * 4
7228
+
7229
+
7230
+ def _prepare_image(path):
7231
+ """Open `path` and return a PIL image Claude will accept, or raise ValueError(reason)."""
7232
+ from PIL import Image
7233
+ try:
7234
+ with Image.open(path) as src:
7235
+ src.load() # a truncated or non-image file fails here, not later in the request
7236
+ im = src.copy()
7237
+ except Image.DecompressionBombError:
7238
+ raise ValueError("too many pixels to open safely") from None
7239
+ except Exception as e: # noqa: BLE001
7240
+ raise ValueError(f"not a readable image ({type(e).__name__})") from None
7241
+ # Colour modes PNG cannot hold, or holds oddly: CMYK photos, 16-bit, float, palette.
7242
+ if im.mode == "P":
7243
+ im = im.convert("RGBA" if "transparency" in im.info else "RGB")
7244
+ elif im.mode not in ("RGB", "RGBA", "L", "LA"):
7245
+ im = im.convert("RGBA" if "A" in im.mode else "RGB")
7246
+ if max(im.size) > _IMAGE_MAX_EDGE:
7247
+ im.thumbnail((_IMAGE_MAX_EDGE, _IMAGE_MAX_EDGE), Image.LANCZOS)
7248
+ while _png_b64_len(im) > _IMAGE_MAX_B64:
7249
+ if max(im.size) <= 256:
7250
+ raise ValueError("still over 5 MB after shrinking")
7251
+ im = im.resize((max(1, im.width * 3 // 4), max(1, im.height * 3 // 4)), Image.LANCZOS)
7252
+ return im
7253
+
7254
+
7128
7255
  def _load_turn_images(refs):
7129
- """[(label, path)] → PIL images, skipping any that will not open."""
7130
- out = []
7256
+ """[(label, path)] → (PIL images ready for Claude, [(label, reason)] for the ones that failed)."""
7257
+ out, failed = [], []
7131
7258
  try:
7132
- from PIL import Image
7259
+ import PIL # noqa: F401
7133
7260
  except Exception: # noqa: BLE001
7134
- return out
7261
+ return out, [(label, "image support (Pillow) is not installed") for label, _ in refs]
7135
7262
  for label, path in refs:
7136
7263
  try:
7137
- with Image.open(path) as im:
7138
- im.load()
7139
- out.append(im.copy())
7140
- except Exception as e: # noqa: BLE001
7141
- _log(f"image {label}: could not open ({type(e).__name__})")
7142
- return out
7264
+ out.append(_prepare_image(path))
7265
+ except ValueError as e:
7266
+ failed.append((label, str(e)))
7267
+ _log(f"image {label}: not attached — {e}")
7268
+ return out, failed
7269
+
7270
+
7271
+ def _label_failed_images(text, failed):
7272
+ """Say which images did not make it, where the model reads their labels — a bare
7273
+ "[Image #1]" with nothing attached invites the model to guess what it shows."""
7274
+ for label, reason in failed:
7275
+ text = text.replace(label, f"{label} (could not be opened: {reason})")
7276
+ return text
7143
7277
 
7144
7278
 
7145
7279
  def run_agent_chat(cfg):
@@ -12228,6 +12362,11 @@ def run_agent_chat(cfg):
12228
12362
  os.makedirs(logs_dir, exist_ok=True)
12229
12363
  log_path = os.path.join(logs_dir, f"{_t.strftime('%Y%m%d-%H%M%S')}-{os.getpid()}.log")
12230
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}")
12231
12370
  # Scrubbed env: repo scripts must never see worker secrets.
12232
12371
  proc = subprocess.Popen(
12233
12372
  argv, cwd=wd, env=_ws_scrubbed_env(),
@@ -12807,9 +12946,14 @@ def run_agent_chat(cfg):
12807
12946
  _turn_images = []
12808
12947
  if _turn_image_refs:
12809
12948
  if _is_anthropic_url(coding_base_url):
12810
- _turn_images = _load_turn_images(_turn_image_refs)
12811
- _log(f"attached {len(_turn_images)} image(s) to the question")
12812
- _emit({"type": "step", "text": f"Sending {len(_turn_images)} image(s) with the question"})
12949
+ _turn_images, _img_failed = _load_turn_images(_turn_image_refs)
12950
+ task_with_hint = _label_failed_images(task_with_hint, _img_failed)
12951
+ _log(f"attached {len(_turn_images)} image(s) to the question"
12952
+ + (f", {len(_img_failed)} could not be opened" if _img_failed else ""))
12953
+ if _turn_images:
12954
+ _emit({"type": "step", "text": f"Sending {len(_turn_images)} image(s) with the question"})
12955
+ for _lbl, _why in _img_failed:
12956
+ _emit({"type": "step", "text": f"⚠ {_lbl} not sent — {_why}"})
12813
12957
  else:
12814
12958
  task_with_hint += ("\n\n(The user pasted " + ", ".join(l for l, _ in _turn_image_refs)
12815
12959
  + " but this coding model is not sent images — say so if the "
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
@@ -20,13 +20,47 @@
20
20
  * most reliable way; Windows Terminal quotes a path with spaces.
21
21
  */
22
22
  import { spawnSync } from "node:child_process";
23
- import { chmodSync, copyFileSync, existsSync, mkdirSync, statSync } from "node:fs";
23
+ import { chmodSync, closeSync, copyFileSync, existsSync, mkdirSync, openSync, readSync, rmSync, statSync } from "node:fs";
24
24
  import { homedir } from "node:os";
25
- import path, { extname, join } from "node:path";
25
+ import path, { join } from "node:path";
26
26
 
27
27
  export const IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]);
28
- /** Anthropic's limit per image. A bigger one would fail the whole request. */
29
- export const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
28
+ /**
29
+ * The largest file gu takes at paste time. NOT Claude's 5 MB limit: the agent shrinks every image
30
+ * to what Claude accepts before sending (task #285), so a 12 MB photo is fine. This only stops a
31
+ * file far too big to be a screenshot from being copied around.
32
+ */
33
+ export const MAX_IMAGE_BYTES = 30 * 1024 * 1024;
34
+
35
+ /**
36
+ * What the file's first bytes say it is — "png" | "jpeg" | "gif" | "webp" | "" (task #285). The
37
+ * EXTENSION is only a name: a renamed text file, or a screenshot still being written, has the
38
+ * right one and is not an image.
39
+ */
40
+ export function imageKind(bytes) {
41
+ const b = Buffer.from(bytes ?? []);
42
+ if (b.length >= 8 && b.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return "png";
43
+ if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return "jpeg";
44
+ if (b.length >= 6 && /^GIF8[79]a$/.test(b.subarray(0, 6).toString("latin1"))) return "gif";
45
+ if (b.length >= 12 && b.subarray(0, 4).toString("latin1") === "RIFF" && b.subarray(8, 12).toString("latin1") === "WEBP") return "webp";
46
+ return "";
47
+ }
48
+
49
+ function fileImageKind(path) {
50
+ let fd;
51
+ try {
52
+ fd = openSync(path, "r");
53
+ const buf = Buffer.alloc(12);
54
+ const n = readSync(fd, buf, 0, 12, 0);
55
+ return imageKind(buf.subarray(0, n));
56
+ } catch {
57
+ return "";
58
+ } finally {
59
+ if (fd !== undefined) closeSync(fd);
60
+ }
61
+ }
62
+
63
+ const NOT_AN_IMAGE = "that file is not an image (png, jpg, gif or webp)";
30
64
 
31
65
  export const imagesDir = (home = homedir()) => join(home, ".gonext", "images");
32
66
 
@@ -41,6 +75,19 @@ export function isClaudeCoderUrl(url) {
41
75
 
42
76
  export const IMAGE_NEEDS_CLAUDE =
43
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
+ }
44
91
 
45
92
  /** The key to name in hints on this platform (both work everywhere). */
46
93
  export const imagePasteKey = (platform = process.platform) => (platform === "win32" ? "Alt+V" : "Ctrl+V");
@@ -97,13 +144,15 @@ export function storeImageFile(src, { home = homedir() } = {}) {
97
144
  return { error: `cannot read ${src}` };
98
145
  }
99
146
  if (size > MAX_IMAGE_BYTES) return { error: tooBig(size) };
100
- const dest = newImagePath(extname(src).toLowerCase() === ".jpeg" ? ".jpg" : extname(src).toLowerCase(), home);
147
+ const kind = fileImageKind(src);
148
+ if (!kind) return { error: NOT_AN_IMAGE };
149
+ const dest = newImagePath(kind === "jpeg" ? ".jpg" : `.${kind}`, home);
101
150
  copyFileSync(src, dest);
102
151
  chmodSync(dest, 0o600);
103
152
  return { path: dest };
104
153
  }
105
154
 
106
- const tooBig = (n) => `the image is ${(n / 1048576).toFixed(1)} MB — Claude accepts up to 5 MB`;
155
+ const tooBig = (n) => `the image is ${(n / 1048576).toFixed(1)} MB — gu takes up to ${MAX_IMAGE_BYTES / 1048576} MB`;
107
156
 
108
157
  /** The command that writes the clipboard's image to `dest` as PNG, per platform (null = none). */
109
158
  export function clipboardImageCommand(dest, platform = process.platform) {
@@ -148,8 +197,19 @@ export function clipboardImageToFile({ home = homedir(), platform = process.plat
148
197
  } catch {
149
198
  /* nothing written */
150
199
  }
151
- if (r?.status !== 0 || size === 0) return { none: true };
152
- if (size > MAX_IMAGE_BYTES) return { error: tooBig(size) };
200
+ if (r?.status !== 0 || size === 0) {
201
+ rmSync(dest, { force: true });
202
+ return { none: true };
203
+ }
204
+ if (size > MAX_IMAGE_BYTES) {
205
+ rmSync(dest, { force: true });
206
+ return { error: tooBig(size) };
207
+ }
208
+ // A file copied in Explorer is copied as-is, so it can be anything with an image's name.
209
+ if (!fileImageKind(dest)) {
210
+ rmSync(dest, { force: true });
211
+ return { error: "the clipboard's file is not an image (png, jpg, gif or webp)" };
212
+ }
153
213
  chmodSync(dest, 0o600);
154
214
  return { path: dest };
155
215
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.752",
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",
@@ -62,7 +62,7 @@ const snippet = [
62
62
  "spec = importlib.util.spec_from_file_location('a', sys.argv[1]); a = importlib.util.module_from_spec(spec); spec.loader.exec_module(a)",
63
63
  "msgs = [{'role': 'user', 'content': 'look [Image #1: ' + sys.argv[2] + '] and [Image #2: ' + sys.argv[3] + ']'}]",
64
64
  "refs = a._take_image_markers(msgs)",
65
- "imgs = a._load_turn_images(refs)",
65
+ "imgs, failed = a._load_turn_images(refs)",
66
66
  "print(json.dumps({'refs': refs, 'text': msgs[0]['content'], 'opened': len(imgs)}))",
67
67
  ].join("\n");
68
68
  const r = spawnSync(py.cmd, [...py.args, "-c", snippet, join(WORKER, "gonext_agent_chat.py"), stored.path ?? "", shot], {
@@ -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
  ];