@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.
- package/gonext_agent_chat.py +157 -13
- package/gu-cli.mjs +0 -3
- package/gu-repl.mjs +17 -6
- package/image-paste.mjs +68 -8
- package/package.json +1 -1
- package/scripts/image-paste-check.mjs +1 -1
- package/terminal-layout.mjs +13 -2
package/gonext_agent_chat.py
CHANGED
|
@@ -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,
|
|
7130
|
-
out = []
|
|
7256
|
+
"""[(label, path)] → (PIL images ready for Claude, [(label, reason)] for the ones that failed)."""
|
|
7257
|
+
out, failed = [], []
|
|
7131
7258
|
try:
|
|
7132
|
-
|
|
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
|
-
|
|
7138
|
-
|
|
7139
|
-
|
|
7140
|
-
|
|
7141
|
-
|
|
7142
|
-
|
|
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
|
-
|
|
12812
|
-
|
|
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
|
-
|
|
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, {
|
|
25
|
+
import path, { join } from "node:path";
|
|
26
26
|
|
|
27
27
|
export const IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]);
|
|
28
|
-
/**
|
|
29
|
-
|
|
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
|
|
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 —
|
|
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)
|
|
152
|
-
|
|
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.
|
|
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], {
|
package/terminal-layout.mjs
CHANGED
|
@@ -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 +
|
|
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
|
];
|