@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.
- package/gonext_agent_chat.py +91 -0
- package/gu-cli.mjs +0 -3
- package/gu-repl.mjs +17 -6
- package/image-paste.mjs +13 -0
- package/package.json +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:
|
|
@@ -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
|
-
|
|
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.
|
|
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",
|
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
|
];
|