@tiens.nguyen/gu-cli 1.0.812 → 1.0.814
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 +62 -3
- package/gu-cli.mjs +12 -0
- package/gu-repl.mjs +89 -5
- package/package.json +2 -1
- package/terminal-layout.mjs +95 -3
package/gonext_agent_chat.py
CHANGED
|
@@ -2628,6 +2628,10 @@ _RAG_STEP: dict = {"n": 0}
|
|
|
2628
2628
|
# "unavailable" — RAG isn't configured at all
|
|
2629
2629
|
# A metric that cannot tell "it wasn't needed" from "it couldn't have worked" answers nothing.
|
|
2630
2630
|
_RAG_STATE: dict = {"s": "unknown"}
|
|
2631
|
+
# Issue #120: mid-turn auto-compaction, per turn. n = passes that actually saved something,
|
|
2632
|
+
# tok = the prompt size (tokens) that triggered the latest one. Rides on the prompt breakdown
|
|
2633
|
+
# so the REPL can show a "compacting context" note on the bottom bar.
|
|
2634
|
+
_COMPACT_STATE: dict = {"n": 0, "tok": 0}
|
|
2631
2635
|
# Is this turn's index on LOCAL disk? Module-level because _rag_known_sources() reads it from
|
|
2632
2636
|
# outside run_agent_chat — and it had no module-level definition at all, so that function raised
|
|
2633
2637
|
# NameError on EVERY call, in every mode. Found by testing the three storage modes (#180).
|
|
@@ -2979,6 +2983,9 @@ def _prompt_breakdown(messages, total_tokens: int, step: int = 0, ledger=None) -
|
|
|
2979
2983
|
"grepCalls": int(_SEARCH_TALLY.get("grep") or 0),
|
|
2980
2984
|
"grepHits": int(_SEARCH_TALLY.get("grep_hits") or 0),
|
|
2981
2985
|
"readCalls": int(_SEARCH_TALLY.get("read") or 0),
|
|
2986
|
+
# Issue #120: how often this turn auto-compacted, and at what size (tokens).
|
|
2987
|
+
"compacted": int(_COMPACT_STATE.get("n") or 0),
|
|
2988
|
+
"compactedAt": int(_COMPACT_STATE.get("tok") or 0),
|
|
2982
2989
|
}
|
|
2983
2990
|
|
|
2984
2991
|
|
|
@@ -4769,6 +4776,42 @@ def _collapse_inner_openers(text):
|
|
|
4769
4776
|
return text[:body_from] + cleaned + "\n</code>"
|
|
4770
4777
|
|
|
4771
4778
|
|
|
4779
|
+
# DeepSeek DSML tool-call markup (issue #119). Some DeepSeek-family models (seen via
|
|
4780
|
+
# token-plan.maas.qwencloudapi.com) answer with their NATIVE tool-call text instead of our code
|
|
4781
|
+
# block: prose, then a DSML "calls" tag, a DSML invoke tag with name="code", then plain Python,
|
|
4782
|
+
# then closers that are often wrong (a DSML "parameter" close) or missing. The bars in the tags
|
|
4783
|
+
# are FULLWIDTH, so the patterns tolerate any short run of non-bracket characters around DSML.
|
|
4784
|
+
_DSML_INVOKE_CODE = re.compile(
|
|
4785
|
+
r"<[^<>\n]{0,8}DSML[^<>\n]{0,8}invoke\s+name\s*=\s*[\x22\x27]code[\x22\x27]\s*>[ \t]*\r?\n?"
|
|
4786
|
+
)
|
|
4787
|
+
_DSML_ANY = re.compile(r"<[^<>\n]{0,8}DSML")
|
|
4788
|
+
_DSML_CLOSER = re.compile(r"\n?[ \t]*</[^<>\n]{0,8}DSML")
|
|
4789
|
+
|
|
4790
|
+
|
|
4791
|
+
def _dsml_to_code_block(text):
|
|
4792
|
+
"""Rewrite a DSML invoke name="code" body into a normal code block, or return the text
|
|
4793
|
+
unchanged. Fires only when there is NO code tag already and the body COMPILES (the same
|
|
4794
|
+
conservative contract as the other readings)."""
|
|
4795
|
+
if not text or "DSML" not in text or re.search(r"<\s*/?\s*code\b", text, re.I):
|
|
4796
|
+
return text
|
|
4797
|
+
m = _DSML_INVOKE_CODE.search(text)
|
|
4798
|
+
if not m:
|
|
4799
|
+
return text
|
|
4800
|
+
rest = text[m.end():]
|
|
4801
|
+
c = _DSML_CLOSER.search(rest)
|
|
4802
|
+
body = (rest[:c.start()] if c else rest).strip("\n")
|
|
4803
|
+
if not body.strip():
|
|
4804
|
+
return text
|
|
4805
|
+
try:
|
|
4806
|
+
compile(body, "<gu-code>", "exec")
|
|
4807
|
+
except Exception: # noqa: BLE001
|
|
4808
|
+
return text
|
|
4809
|
+
head = text[:m.start()]
|
|
4810
|
+
first = _DSML_ANY.search(head)
|
|
4811
|
+
prose = (head[:first.start()] if first else head).rstrip()
|
|
4812
|
+
return (prose + "\n" if prose else "") + "<code>\n" + body + "\n</code>"
|
|
4813
|
+
|
|
4814
|
+
|
|
4772
4815
|
def _code_block_readings(text):
|
|
4773
4816
|
"""Every plausible reading of where the code block is, CHEAPEST FIRST.
|
|
4774
4817
|
|
|
@@ -4829,6 +4872,10 @@ def _canonicalise_code_reply(text):
|
|
|
4829
4872
|
# reproduced, inside the fix, the very shape of the bug being fixed: a strict cheap check
|
|
4830
4873
|
# guarding a tolerant repair. If a pre-check cannot be as permissive as what it guards, it
|
|
4831
4874
|
# does not belong in front of it.
|
|
4875
|
+
_dsml = _dsml_to_code_block(text)
|
|
4876
|
+
if _dsml != text:
|
|
4877
|
+
_log("DSML tool-call markup converted to a code block (#119)")
|
|
4878
|
+
return _dsml
|
|
4832
4879
|
if not text or not _CODE_ANY_TAGGISH.search(text):
|
|
4833
4880
|
return text
|
|
4834
4881
|
fallback = None
|
|
@@ -8279,6 +8326,8 @@ def run_agent_chat(cfg):
|
|
|
8279
8326
|
# credit RAG for tokens it never cost, and a wrong number is worse than none.
|
|
8280
8327
|
_rag_ledger_reset()
|
|
8281
8328
|
_RAG_STEP["n"] = 0
|
|
8329
|
+
_COMPACT_STATE["n"] = 0
|
|
8330
|
+
_COMPACT_STATE["tok"] = 0
|
|
8282
8331
|
_overload_notified["yes"] = False
|
|
8283
8332
|
try:
|
|
8284
8333
|
from smolagents import CodeAgent, OpenAIServerModel, tool
|
|
@@ -10575,8 +10624,11 @@ def run_agent_chat(cfg):
|
|
|
10575
10624
|
# Yes/No before deep-compacting (the auto-trim above is always-on; THIS is
|
|
10576
10625
|
# the extra, opt-in aggressive pass). Only in the terminal (interactive) —
|
|
10577
10626
|
# web has no picker, so it just keeps the auto-trim.
|
|
10578
|
-
|
|
10579
|
-
|
|
10627
|
+
# Issue #120: this used to be gated on an interactive terminal AND read
|
|
10628
|
+
# _compact_state["decision"], a key removed with the old Yes/No prompt — the
|
|
10629
|
+
# KeyError was swallowed below as "memory-trim skip", so mid-turn compaction
|
|
10630
|
+
# NEVER ran and the context grew past the model's window. Always on now.
|
|
10631
|
+
if _steps:
|
|
10580
10632
|
# Size of the last request in prompt tokens (exact if the server
|
|
10581
10633
|
# reported it, else ~chars/4 of the current step memory).
|
|
10582
10634
|
_tu = getattr(step_log, "token_usage", None)
|
|
@@ -10612,6 +10664,12 @@ def run_agent_chat(cfg):
|
|
|
10612
10664
|
f"{_VERBOSE_PREFIX}Context reached ~{_in_tok // 1000}k tokens — trimming older "
|
|
10613
10665
|
"steps to stay fast (files are re-readable)")})
|
|
10614
10666
|
if _cs:
|
|
10667
|
+
_COMPACT_STATE["n"] += 1
|
|
10668
|
+
_COMPACT_STATE["tok"] = int(_in_tok)
|
|
10669
|
+
# Issue #120: tell the terminal NOW (not on the next request's
|
|
10670
|
+
# breakdown) so it can lock input and show the compacting bar.
|
|
10671
|
+
_emit({"type": "compact", "n": _COMPACT_STATE["n"],
|
|
10672
|
+
"tok": _COMPACT_STATE["tok"]})
|
|
10615
10673
|
_log(f"context compact: saved ~{_cs} chars "
|
|
10616
10674
|
f"(at ~{_in_tok} tok, threshold {_compact_at}, "
|
|
10617
10675
|
f"window {_ctx_window or 'unknown'})")
|
|
@@ -11825,7 +11883,8 @@ def run_agent_chat(cfg):
|
|
|
11825
11883
|
if _still_bad:
|
|
11826
11884
|
self._parse_fail_streak += 1
|
|
11827
11885
|
_log("tool-call code still unparseable after repair "
|
|
11828
|
-
f"(streak {self._parse_fail_streak}/3)"
|
|
11886
|
+
f"(streak {self._parse_fail_streak}/3) model={getattr(self, 'model_id', '?')} "
|
|
11887
|
+
f"reply[:300]={(msg.content or '')[:300]!r}")
|
|
11829
11888
|
if self._parse_fail_streak >= 3:
|
|
11830
11889
|
_log("parse-fail streak hit limit → ending the turn honestly "
|
|
11831
11890
|
"instead of looping on an unparseable tool call")
|
package/gu-cli.mjs
CHANGED
|
@@ -4394,6 +4394,18 @@ async function runAgentChatJob(job) {
|
|
|
4394
4394
|
}),
|
|
4395
4395
|
}).catch(() => {});
|
|
4396
4396
|
}
|
|
4397
|
+
} else if (event.type === "compact" && Number.isFinite(event.n)) {
|
|
4398
|
+
// Issue #120: a mid-turn compaction just landed — put it on the job straight away so
|
|
4399
|
+
// the REPL locks input and shows its progress bar now, not at the next request.
|
|
4400
|
+
latestPromptBreakdown = {
|
|
4401
|
+
...(latestPromptBreakdown || {}),
|
|
4402
|
+
compacted: event.n,
|
|
4403
|
+
...(Number.isFinite(event.tok) ? { compactedAt: event.tok } : {}),
|
|
4404
|
+
};
|
|
4405
|
+
workerFetch(`/api/worker/jobs/${jobId}`, {
|
|
4406
|
+
method: "PATCH",
|
|
4407
|
+
body: JSON.stringify({ agentPromptBreakdown: latestPromptBreakdown }),
|
|
4408
|
+
}).catch(() => {});
|
|
4397
4409
|
} else if (event.type === "code_request") {
|
|
4398
4410
|
// Task #155: the row is opened BEFORE the request goes out, so a step that is
|
|
4399
4411
|
// cancelled, hangs, or is refused still leaves a readable record of exactly what was
|
package/gu-repl.mjs
CHANGED
|
@@ -61,6 +61,9 @@ import {
|
|
|
61
61
|
thoughtFull,
|
|
62
62
|
thoughtBlockRows,
|
|
63
63
|
ragSegmentLabel,
|
|
64
|
+
ragBarNote,
|
|
65
|
+
compactBarNote,
|
|
66
|
+
compactProgressNote,
|
|
64
67
|
searchSegmentLabel,
|
|
65
68
|
runningDetailRows,
|
|
66
69
|
formatThinkTime,
|
|
@@ -548,6 +551,8 @@ const brightText = (s) => s; // default terminal colour (un-dimmed) — the "pop
|
|
|
548
551
|
// forward reference here is a startup crash, not a lint warning.
|
|
549
552
|
const BULLET_STYLE = {
|
|
550
553
|
fail: { mark: CROSS, markColor: red, textColor: brightText },
|
|
554
|
+
// A look-up (grep/pgrep/rg…) that found nothing: not an error — a dim grey ✗, dim text.
|
|
555
|
+
soft: { mark: CROSS, markColor: dim, textColor: dim },
|
|
551
556
|
ok: { mark: CHECK, markColor: accent, textColor: dim },
|
|
552
557
|
info: { mark: BULLET, markColor: dim, textColor: dim },
|
|
553
558
|
};
|
|
@@ -2276,9 +2281,13 @@ const ragSegmentText = () => {
|
|
|
2276
2281
|
// RAG FAILED MID-TURN and searches were answered by the grep fallback: a WARNING (yellow),
|
|
2277
2282
|
// always visible — the turn still got results, but not semantic ones.
|
|
2278
2283
|
const liveFallback = b.ragState === "fallback" && !b.stale;
|
|
2279
|
-
|
|
2284
|
+
// Task #34: a LIVE grep fallback (embedder down, or RAG failed mid-turn) is no longer on this
|
|
2285
|
+
// row. In red with a "✗" it read as a serious fault while search kept working. It is now a soft
|
|
2286
|
+
// yellow note on the bottom bar's mode row (ragBarNote → footerRows' modeNote).
|
|
2287
|
+
if (liveDown || liveFallback) return lexSeg;
|
|
2288
|
+
if (!REPL_VERBOSE) return lexSeg;
|
|
2280
2289
|
if (b.stale) return dim(" · ") + dim(`${label}·`) + lexSeg;
|
|
2281
|
-
return dim(" · ") +
|
|
2290
|
+
return dim(" · ") + dim(label) + lexSeg;
|
|
2282
2291
|
};
|
|
2283
2292
|
/**
|
|
2284
2293
|
* IS ANYTHING NEWER? (task #181) — cached, never probed from a repaint.
|
|
@@ -2721,6 +2730,39 @@ const ragLineText = () => {
|
|
|
2721
2730
|
// that counts rows with ESC[nA / ESC[nB (the #109 failure mode). Called fresh on every draw,
|
|
2722
2731
|
// so a resize simply re-lays it out (task #138); the RAG row is verbose-only, which is why the
|
|
2723
2732
|
// row COUNT is taken from this array rather than hard-coded anywhere.
|
|
2733
|
+
// Issue #120: while a mid-turn compaction runs, EVERYTHING is locked — keys are swallowed, the
|
|
2734
|
+
// turn's output is held back — and only the progress bar on the bottom bar moves. The trim
|
|
2735
|
+
// itself takes milliseconds, so the lock lasts a short fixed window the user can actually see.
|
|
2736
|
+
// Ctrl+C (SIGINT) still cancels the turn: a lock must never trap the user.
|
|
2737
|
+
const COMPACT_LOCK_MS = 1500;
|
|
2738
|
+
let compactLock = null; // { startedAt, until } while locked
|
|
2739
|
+
let compactSeenPasses = 0; // compaction passes already shown this turn
|
|
2740
|
+
let compactLockTimer = null;
|
|
2741
|
+
const compactLocked = () => !!compactLock && Date.now() < compactLock.until;
|
|
2742
|
+
function startCompactLock() {
|
|
2743
|
+
const now = Date.now();
|
|
2744
|
+
compactLock = { startedAt: now, until: now + COMPACT_LOCK_MS };
|
|
2745
|
+
if (compactLockTimer) clearInterval(compactLockTimer);
|
|
2746
|
+
compactLockTimer = setInterval(() => {
|
|
2747
|
+
if (!compactLocked()) endCompactLock();
|
|
2748
|
+
else if (barPinned()) paintBar();
|
|
2749
|
+
}, 80);
|
|
2750
|
+
if (barPinned()) paintBar();
|
|
2751
|
+
}
|
|
2752
|
+
function endCompactLock() {
|
|
2753
|
+
if (compactLockTimer) clearInterval(compactLockTimer);
|
|
2754
|
+
compactLockTimer = null;
|
|
2755
|
+
const was = !!compactLock;
|
|
2756
|
+
compactLock = null;
|
|
2757
|
+
if (was && barPinned()) paintBar();
|
|
2758
|
+
}
|
|
2759
|
+
const compactLockNote = () =>
|
|
2760
|
+
compactLock
|
|
2761
|
+
? compactProgressNote({
|
|
2762
|
+
frac: (Date.now() - compactLock.startedAt) / COMPACT_LOCK_MS,
|
|
2763
|
+
at: lastPromptBreakdown?.compactedAt,
|
|
2764
|
+
})
|
|
2765
|
+
: "";
|
|
2724
2766
|
const footerRows = () =>
|
|
2725
2767
|
buildFooterRows({
|
|
2726
2768
|
width: process.stdout.columns || 80,
|
|
@@ -2729,6 +2771,11 @@ const footerRows = () =>
|
|
|
2729
2771
|
identity: identityLineText(),
|
|
2730
2772
|
token: tokenLineText(),
|
|
2731
2773
|
mode: modeLineText(),
|
|
2774
|
+
// Task #34: "rag: embedder down → grep fallback" as a soft yellow note beside the mode, not an
|
|
2775
|
+
// alarming red segment on the token row. yellow goes through paint(), so NO_COLOR turns it off.
|
|
2776
|
+
// Issue #120: an auto-compaction this turn outranks the rag note (same soft yellow slot).
|
|
2777
|
+
modeNote: compactLockNote() || compactBarNote(lastPromptBreakdown) || ragBarNote(lastPromptBreakdown),
|
|
2778
|
+
notePaint: yellow,
|
|
2732
2779
|
// How to paste here (issue #67): the platform's text key, plus gu's image key when the coder
|
|
2733
2780
|
// can take an image.
|
|
2734
2781
|
modeHint: pasteHint({ images: coderTakesImages() }),
|
|
@@ -3540,6 +3587,7 @@ function printThoughtBlock(raw, before, { gap = false } = {}) {
|
|
|
3540
3587
|
collapsed: true,
|
|
3541
3588
|
lines: THOUGHT_COLLAPSED_LINES,
|
|
3542
3589
|
});
|
|
3590
|
+
const rowsBeforeMerge = screenRows().length;
|
|
3543
3591
|
const newEnd = transcript.replaceRange(prev.from, prev.to, grown.map((r) => GUTTER + dim(r)));
|
|
3544
3592
|
if (newEnd !== null) {
|
|
3545
3593
|
prev.prose = merged;
|
|
@@ -3548,7 +3596,7 @@ function printThoughtBlock(raw, before, { gap = false } = {}) {
|
|
|
3548
3596
|
// move under the pointer, so the cached hover row must be dropped — same reason
|
|
3549
3597
|
// toggleThoughtBlock does it after a resize of the block.
|
|
3550
3598
|
hoverCtl.reset();
|
|
3551
|
-
|
|
3599
|
+
repaintTailOrHoldView(rowsBeforeMerge); // #113/#35: a finished thought never pulls a reader down
|
|
3552
3600
|
return true;
|
|
3553
3601
|
}
|
|
3554
3602
|
// Scrolled out of the buffer — fall through and print a new block rather than lose it.
|
|
@@ -3855,6 +3903,21 @@ function toggleNewestThought() {
|
|
|
3855
3903
|
return toggleThoughtBlock(block);
|
|
3856
3904
|
}
|
|
3857
3905
|
|
|
3906
|
+
/**
|
|
3907
|
+
* #113 / #35 — A BLOCK AT THE TAIL CHANGED SIZE (a thought merged, a step group grew). At the
|
|
3908
|
+
* live tail that is a paintLiveTail. Scrolled back, it is NOT: paintLiveTail sets scrollOffset
|
|
3909
|
+
* to 0, which is exactly the "it finished thinking and threw me back to the bottom" report. The
|
|
3910
|
+
* change happened below (or at the edge of) what the reader is looking at, so keep the same
|
|
3911
|
+
* lines on screen — the offset moves by the rows added — and let the jump notice count up.
|
|
3912
|
+
*/
|
|
3913
|
+
function repaintTailOrHoldView(rowsBefore) {
|
|
3914
|
+
if (barPinned() && scrolledBack()) {
|
|
3915
|
+
scrollOffset = Math.max(1, scrollOffset + (screenRows().length - rowsBefore));
|
|
3916
|
+
renderScrollback(); // same slice as before; ends in paintBar (the "N lines below" count)
|
|
3917
|
+
return;
|
|
3918
|
+
}
|
|
3919
|
+
paintLiveTail();
|
|
3920
|
+
}
|
|
3858
3921
|
/** Back to the live tail: repaint it from the transcript and hand the screen back. */
|
|
3859
3922
|
function jumpToBottom() {
|
|
3860
3923
|
if (!barPinned() || !scrollOffset) return; // already live — nothing to come back from
|
|
@@ -4333,6 +4396,13 @@ if (process.stdin.isTTY) {
|
|
|
4333
4396
|
// re-rendered the slash overlay (the line still starts with "/" from before the paste) and
|
|
4334
4397
|
// re-walked the paste store. Same for a mouse report's characters, which are not keys.
|
|
4335
4398
|
if (pasteBuffer !== null || inMouseKeyWindow()) return;
|
|
4399
|
+
// Issue #120: compacting → every key is locked (Ctrl+C still arrives as SIGINT).
|
|
4400
|
+
if (compactLocked()) {
|
|
4401
|
+
rl.line = "";
|
|
4402
|
+
rl.cursor = 0;
|
|
4403
|
+
rl.historyIndex = -1;
|
|
4404
|
+
return;
|
|
4405
|
+
}
|
|
4336
4406
|
// #247: the restored question was erased (Backspace, Ctrl+U, a picker taking the line) — it
|
|
4337
4407
|
// is let go, exactly as with Ctrl+C, so the next message does not REPLACE it in history.
|
|
4338
4408
|
if (prefillFor && (listPickerActive || restoredQuestionLetGo(rl.line, key))) prefillFor = null;
|
|
@@ -11274,6 +11344,7 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
11274
11344
|
following = true;
|
|
11275
11345
|
followingAttached = attached;
|
|
11276
11346
|
typedDuringTurn = false;
|
|
11347
|
+
compactSeenPasses = 0; // issue #120: python counts passes per turn
|
|
11277
11348
|
followAborted = false;
|
|
11278
11349
|
currentJobId = jobId;
|
|
11279
11350
|
jobIdForApprovalRace = jobId; // so an approval picker can watch THIS job for early end (#110)
|
|
@@ -11876,13 +11947,16 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
11876
11947
|
if (stepGroupStillOpen()) {
|
|
11877
11948
|
stepGroup.items.push(text);
|
|
11878
11949
|
const painted = paintThoughtRows(stepGroup, hoverCtl.hovered === stepGroup);
|
|
11950
|
+
const rowsBeforeStep = screenRows().length;
|
|
11879
11951
|
const newEnd = transcript.replaceRange(stepGroup.from, stepGroup.to, painted);
|
|
11880
11952
|
if (newEnd !== null) {
|
|
11881
11953
|
const delta = newEnd - stepGroup.to;
|
|
11882
11954
|
stepGroup.to = newEnd;
|
|
11883
11955
|
if (delta === 0) {
|
|
11884
11956
|
// Collapsed: one row before, one row after — rewrite that row and nothing else.
|
|
11885
|
-
|
|
11957
|
+
// Scrolled back (#113/#35): those rows are live-tail coordinates, NOT the history the
|
|
11958
|
+
// reader is looking at — painting them would draw over it. The transcript has it.
|
|
11959
|
+
if (!scrolledBack()) ui(thoughtBlockRepaintSeq(stepGroup));
|
|
11886
11960
|
paintBar();
|
|
11887
11961
|
} else {
|
|
11888
11962
|
// Open (the user clicked it mid-turn): it grew, and what is under it moved down.
|
|
@@ -11893,7 +11967,7 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
11893
11967
|
}
|
|
11894
11968
|
}
|
|
11895
11969
|
hoverCtl.reset();
|
|
11896
|
-
|
|
11970
|
+
repaintTailOrHoldView(rowsBeforeStep); // #113/#35: never pull a reader down
|
|
11897
11971
|
}
|
|
11898
11972
|
return;
|
|
11899
11973
|
}
|
|
@@ -12341,6 +12415,12 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
12341
12415
|
if (job.agentPromptBreakdown && typeof job.agentPromptBreakdown === "object") {
|
|
12342
12416
|
lastPromptBreakdown = job.agentPromptBreakdown;
|
|
12343
12417
|
liveStepTokens = readStepTokens(job.agentPromptBreakdown);
|
|
12418
|
+
// Issue #120: a NEW compaction pass this turn → lock everything and show the bar.
|
|
12419
|
+
const compactPasses = Number.isFinite(job.agentPromptBreakdown.compacted) ? job.agentPromptBreakdown.compacted : 0;
|
|
12420
|
+
if (compactPasses > compactSeenPasses) {
|
|
12421
|
+
compactSeenPasses = compactPasses;
|
|
12422
|
+
startCompactLock();
|
|
12423
|
+
}
|
|
12344
12424
|
// Persisted per folder so the next `gu` here starts from the real picture instead
|
|
12345
12425
|
// of a zero that looks like a failure. Fire-and-forget: a bar value is never worth
|
|
12346
12426
|
// delaying a turn for, and losing one is harmless. Shown while attached (it is what
|
|
@@ -12357,6 +12437,9 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
12357
12437
|
// Mirror both into the bottom bar's token row so it counts UP live while the turn runs
|
|
12358
12438
|
// (the bar rides under the status block now), instead of sitting on the last turn's
|
|
12359
12439
|
// figures until this one finishes. The turn-end assignment writes the same numbers again.
|
|
12440
|
+
// Issue #120: hold the turn's output while compacting — only the bar moves. Ctrl+C ends it.
|
|
12441
|
+
while (compactLocked() && !cancelRequested && !followAborted) await sleep(80);
|
|
12442
|
+
if (compactLock && (cancelRequested || followAborted)) endCompactLock();
|
|
12360
12443
|
lastTurnInputTokens = latestCodeTokens;
|
|
12361
12444
|
lastTurnOutputTokens = latestOutputTokens;
|
|
12362
12445
|
lastTurnTotalOutput = globalOutputTokens + latestOutputTokens;
|
|
@@ -12593,6 +12676,7 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
12593
12676
|
following = false;
|
|
12594
12677
|
followingAttached = false;
|
|
12595
12678
|
typedDuringTurn = false;
|
|
12679
|
+
endCompactLock(); // issue #120: never leave the terminal locked after a turn
|
|
12596
12680
|
currentJobId = null;
|
|
12597
12681
|
// Drop anything typed (but not submitted) during the turn — echo was muted, so it's
|
|
12598
12682
|
// invisible; without this it would surface on the next prompt.
|
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.814",
|
|
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",
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
"test:cache-e2e": "GONEXT_OLLAMA1_LIVE=1 node --test --test-timeout=1800000 tests/prefix-cache-e2e.test.mjs",
|
|
41
41
|
"test:tokens": "python3 -m unittest discover tests -p \"test_cached_tokens.py\" -v && node --test tests/prefix-cache.test.mjs tests/agent-turn-shape.test.mjs",
|
|
42
42
|
"test:selection": "node --test tests/selection-scrollback.test.mjs tests/selection-while-thinking.test.mjs tests/terminal-selection.test.mjs tests/terminal-copy.test.mjs tests/terminal-viewport.test.mjs tests/hover-repaint.test.mjs",
|
|
43
|
+
"test:autoscroll": "node --test tests/no-autoscroll-hide-total.test.mjs tests/thought-spacing.test.mjs tests/terminal-viewport.test.mjs tests/selection-scrollback.test.mjs",
|
|
43
44
|
"test:steps": "python3 scripts/py-tap.py tests/test_read_caps.py tests/test_trim_payback.py tests/test_explore.py tests/test_and_chains.py tests/test_edit_file_primary.py tests/test_bare_code_rescue.py tests/test_batching_rule.py tests/test_no_pdf_routing.py",
|
|
44
45
|
"check:image": "node scripts/image-paste-check.mjs",
|
|
45
46
|
"check:image-clipboard": "node scripts/image-paste-check.mjs --clipboard",
|
package/terminal-layout.mjs
CHANGED
|
@@ -146,8 +146,9 @@ export function ragSegmentLabel(b) {
|
|
|
146
146
|
const rag = Number.isFinite(b.rag) ? b.rag : 0;
|
|
147
147
|
if (rag > 0) return null; // …the caller renders the number + percent instead
|
|
148
148
|
switch (b.ragState) {
|
|
149
|
+
// Task #34: no "✗" — search still works through grep, so this is a notice, not an error.
|
|
149
150
|
case "unreachable":
|
|
150
|
-
return "rag
|
|
151
|
+
return "rag: embedder down → grep fallback";
|
|
151
152
|
case "no-index":
|
|
152
153
|
return "rag — not indexed";
|
|
153
154
|
case "unavailable":
|
|
@@ -167,7 +168,7 @@ export function ragSegmentLabel(b) {
|
|
|
167
168
|
// RAG FAILED MID-TURN (indexing or query embedding) and the search was answered by the
|
|
168
169
|
// keyword fallback instead. A warning, not an error: the turn still got results.
|
|
169
170
|
case "fallback":
|
|
170
|
-
return "rag
|
|
171
|
+
return "rag: unavailable → grep fallback";
|
|
171
172
|
// Nothing has been measured yet (the state before any turn has run). "0" is a claim we
|
|
172
173
|
// cannot support; a dash says "no figure", which is the truth.
|
|
173
174
|
case "unknown":
|
|
@@ -182,6 +183,62 @@ export function ragSegmentLabel(b) {
|
|
|
182
183
|
}
|
|
183
184
|
}
|
|
184
185
|
|
|
186
|
+
/**
|
|
187
|
+
* The RAG notice for the BOTTOM BAR's mode row (task #34), or "" when there is none.
|
|
188
|
+
*
|
|
189
|
+
* Only THIS session's live verdict that search fell back to grep: "unreachable" (the embedder is
|
|
190
|
+
* down) or "fallback" (RAG failed mid-turn). It used to sit in red with a "✗" in the middle of the
|
|
191
|
+
* token row and read as a serious fault, while every search was still answered by grep. It is
|
|
192
|
+
* now a soft note beside "accept edits on". A stale verdict reloaded from a saved session is
|
|
193
|
+
* not a live fault, so it gets no notice.
|
|
194
|
+
*/
|
|
195
|
+
export function ragBarNote(b) {
|
|
196
|
+
if (!b || typeof b !== "object" || b.stale) return "";
|
|
197
|
+
if (b.ragState !== "unreachable" && b.ragState !== "fallback") return "";
|
|
198
|
+
return ragSegmentLabel(b) || "";
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Issue #120: the bottom bar's note while a turn has AUTO-COMPACTED its context, or "".
|
|
203
|
+
*
|
|
204
|
+
* python trims older steps mid-turn once the prompt reaches ~2/3 of the model's window and
|
|
205
|
+
* reports it on the prompt breakdown (compacted = passes this turn, compactedAt = tokens at
|
|
206
|
+
* the latest one). A stale breakdown reloaded from disk is not this turn, so it gets no note.
|
|
207
|
+
*/
|
|
208
|
+
export function compactBarNote(b) {
|
|
209
|
+
if (!b || typeof b !== "object" || b.stale) return "";
|
|
210
|
+
const n = Number.isFinite(b.compacted) ? b.compacted : 0;
|
|
211
|
+
if (n <= 0) return "";
|
|
212
|
+
const at = Number.isFinite(b.compactedAt) && b.compactedAt > 0
|
|
213
|
+
? ` at ~${Math.round(b.compactedAt / 1000)}k` : "";
|
|
214
|
+
return `⟳ compacting context${at} — older steps trimmed${n > 1 ? ` (${n}×)` : ""}`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Issue #120: the bottom bar's ANIMATED note while a compaction holds the terminal locked.
|
|
219
|
+
* `frac` 0..1 is how far through the lock window we are; `at` the prompt tokens at the trim.
|
|
220
|
+
*/
|
|
221
|
+
export function compactProgressNote({ frac = 0, at = 0, cells = 12 } = {}) {
|
|
222
|
+
const f = Math.max(0, Math.min(1, Number.isFinite(frac) ? frac : 0));
|
|
223
|
+
const full = Math.round(f * cells);
|
|
224
|
+
const bar = "█".repeat(full) + "░".repeat(cells - full);
|
|
225
|
+
const k = Number.isFinite(at) && at > 0 ? ` at ~${Math.round(at / 1000)}k` : "";
|
|
226
|
+
return `⟳ compacting context${k} ▕${bar}▏ ${Math.round(f * 100)}% — input locked`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Cut plain text to `room` visible columns, ending with "…" when it was cut. */
|
|
230
|
+
function fitPlain(s, room) {
|
|
231
|
+
s = String(s ?? "");
|
|
232
|
+
if (visibleLen(s) <= room) return s;
|
|
233
|
+
if (room <= 1) return room === 1 ? "…" : "";
|
|
234
|
+
let out = "";
|
|
235
|
+
for (const ch of s) {
|
|
236
|
+
if (visibleLen(out + ch) > room - 1) break;
|
|
237
|
+
out += ch;
|
|
238
|
+
}
|
|
239
|
+
return out + "…";
|
|
240
|
+
}
|
|
241
|
+
|
|
185
242
|
// The six origin buckets. They are scaled to sum to the server's prompt_tokens for ONE
|
|
186
243
|
// request, so their sum IS the size of the newest context — the number to compare against
|
|
187
244
|
// the model's window. Deliberately excludes ragFresh/ragCarried (shares OF rag, not extra)
|
|
@@ -544,11 +601,25 @@ export function buildFooterRows({
|
|
|
544
601
|
// Right-aligned on the mode row (issue #67), dim. DROPPED WHOLE when it does not fit: every
|
|
545
602
|
// footer row must stay one physical row (#109/#132/#138), and half a key name is worse than none.
|
|
546
603
|
modeHint = "",
|
|
604
|
+
// Task #34: a soft notice (PLAIN text) after the mode text, painted with notePaint. It takes
|
|
605
|
+
// priority over the paste hint and is cut with "…" on a narrow window, never wrapped.
|
|
606
|
+
modeNote = "",
|
|
607
|
+
notePaint = dim,
|
|
547
608
|
notice = "",
|
|
548
609
|
missing = "",
|
|
549
610
|
}) {
|
|
550
611
|
const w = Math.max(20, width || 80);
|
|
551
|
-
|
|
612
|
+
let modeLeft = gutter + mode;
|
|
613
|
+
if (modeNote) {
|
|
614
|
+
const NOTE_GAP = " ";
|
|
615
|
+
const hintRoom = modeHint ? HINT_MIN_GAP + visibleLen(modeHint) : 0;
|
|
616
|
+
let room = w - 1 - visibleLen(modeLeft) - NOTE_GAP.length;
|
|
617
|
+
// Keep the paste hint only when the whole notice still fits beside it.
|
|
618
|
+
if (modeHint && visibleLen(modeNote) <= room - hintRoom) room -= hintRoom;
|
|
619
|
+
else modeHint = "";
|
|
620
|
+
// Too little room to say anything readable: leave the notice out.
|
|
621
|
+
if (room >= 8) modeLeft += NOTE_GAP + notePaint(fitPlain(modeNote, room));
|
|
622
|
+
}
|
|
552
623
|
const hintGap = modeHint ? w - 1 - visibleLen(modeLeft) - visibleLen(modeHint) : -1;
|
|
553
624
|
const modeRow =
|
|
554
625
|
hintGap >= HINT_MIN_GAP
|
|
@@ -1071,7 +1142,28 @@ export function trimWords(text, limit) {
|
|
|
1071
1142
|
return toks.join(" ").trimEnd() + "…";
|
|
1072
1143
|
}
|
|
1073
1144
|
|
|
1145
|
+
// A LOOK-UP command (grep, rg, pgrep, which, lsof…) exits non-zero when it simply finds
|
|
1146
|
+
// nothing. That is an answer ("not there"), not a fault — so a "Command failed → pgrep -f x"
|
|
1147
|
+
// must not pop as a red ✗ (#41's "the one thing you must not miss"). It is "soft": a dim
|
|
1148
|
+
// grey ✗ with dim text. Judged by the LAST segment of a chain/pipeline (its exit status is
|
|
1149
|
+
// the one reported): `ps aux | grep foo` and `cd src && rg Foo` are both look-ups.
|
|
1150
|
+
const SEARCH_CMDS = new Set([
|
|
1151
|
+
"grep", "egrep", "fgrep", "zgrep", "rg", "ag", "ack", "pgrep", "which", "whereis", "lsof",
|
|
1152
|
+
]);
|
|
1153
|
+
export function isSoftFailCommand(cmd) {
|
|
1154
|
+
const segs = String(cmd ?? "").split(/&&|\|\||[|;]/).map((s) => s.trim()).filter(Boolean);
|
|
1155
|
+
if (!segs.length) return false;
|
|
1156
|
+
const toks = segs[segs.length - 1].split(/\s+/);
|
|
1157
|
+
let i = 0;
|
|
1158
|
+
while (i < toks.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(toks[i]) || ["env", "command", "time", "nice"].includes(toks[i]))) i++;
|
|
1159
|
+
const prog = (toks[i] || "").split("/").pop();
|
|
1160
|
+
if (SEARCH_CMDS.has(prog)) return true;
|
|
1161
|
+
return prog === "git" && toks[i + 1] === "grep";
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1074
1164
|
export function bulletKind(text) {
|
|
1165
|
+
const mFail = /^Command failed → (.+)$/.exec(String(text ?? "").trim());
|
|
1166
|
+
if (mFail && isSoftFailCommand(mFail[1])) return "soft";
|
|
1075
1167
|
if (/\bfail(ed|s|ure)?\b|\berror(ed)?\b|→\s*(?:HTTP\s*)?[45]\d\d\b/i.test(text)) return "fail";
|
|
1076
1168
|
if (/^(?:Command finished|Server up|App launched)\b|→\s*(?:HTTP\s*)?2\d\d\b|\b(?:passed|succeeded|ready)\b/i.test(text))
|
|
1077
1169
|
return "ok";
|