@tiens.nguyen/gu-cli 1.0.812 → 1.0.813

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.
@@ -4769,6 +4769,42 @@ def _collapse_inner_openers(text):
4769
4769
  return text[:body_from] + cleaned + "\n</code>"
4770
4770
 
4771
4771
 
4772
+ # DeepSeek DSML tool-call markup (issue #119). Some DeepSeek-family models (seen via
4773
+ # token-plan.maas.qwencloudapi.com) answer with their NATIVE tool-call text instead of our code
4774
+ # block: prose, then a DSML "calls" tag, a DSML invoke tag with name="code", then plain Python,
4775
+ # then closers that are often wrong (a DSML "parameter" close) or missing. The bars in the tags
4776
+ # are FULLWIDTH, so the patterns tolerate any short run of non-bracket characters around DSML.
4777
+ _DSML_INVOKE_CODE = re.compile(
4778
+ r"<[^<>\n]{0,8}DSML[^<>\n]{0,8}invoke\s+name\s*=\s*[\x22\x27]code[\x22\x27]\s*>[ \t]*\r?\n?"
4779
+ )
4780
+ _DSML_ANY = re.compile(r"<[^<>\n]{0,8}DSML")
4781
+ _DSML_CLOSER = re.compile(r"\n?[ \t]*</[^<>\n]{0,8}DSML")
4782
+
4783
+
4784
+ def _dsml_to_code_block(text):
4785
+ """Rewrite a DSML invoke name="code" body into a normal code block, or return the text
4786
+ unchanged. Fires only when there is NO code tag already and the body COMPILES (the same
4787
+ conservative contract as the other readings)."""
4788
+ if not text or "DSML" not in text or re.search(r"<\s*/?\s*code\b", text, re.I):
4789
+ return text
4790
+ m = _DSML_INVOKE_CODE.search(text)
4791
+ if not m:
4792
+ return text
4793
+ rest = text[m.end():]
4794
+ c = _DSML_CLOSER.search(rest)
4795
+ body = (rest[:c.start()] if c else rest).strip("\n")
4796
+ if not body.strip():
4797
+ return text
4798
+ try:
4799
+ compile(body, "<gu-code>", "exec")
4800
+ except Exception: # noqa: BLE001
4801
+ return text
4802
+ head = text[:m.start()]
4803
+ first = _DSML_ANY.search(head)
4804
+ prose = (head[:first.start()] if first else head).rstrip()
4805
+ return (prose + "\n" if prose else "") + "<code>\n" + body + "\n</code>"
4806
+
4807
+
4772
4808
  def _code_block_readings(text):
4773
4809
  """Every plausible reading of where the code block is, CHEAPEST FIRST.
4774
4810
 
@@ -4829,6 +4865,10 @@ def _canonicalise_code_reply(text):
4829
4865
  # reproduced, inside the fix, the very shape of the bug being fixed: a strict cheap check
4830
4866
  # guarding a tolerant repair. If a pre-check cannot be as permissive as what it guards, it
4831
4867
  # does not belong in front of it.
4868
+ _dsml = _dsml_to_code_block(text)
4869
+ if _dsml != text:
4870
+ _log("DSML tool-call markup converted to a code block (#119)")
4871
+ return _dsml
4832
4872
  if not text or not _CODE_ANY_TAGGISH.search(text):
4833
4873
  return text
4834
4874
  fallback = None
@@ -11825,7 +11865,8 @@ def run_agent_chat(cfg):
11825
11865
  if _still_bad:
11826
11866
  self._parse_fail_streak += 1
11827
11867
  _log("tool-call code still unparseable after repair "
11828
- f"(streak {self._parse_fail_streak}/3)")
11868
+ f"(streak {self._parse_fail_streak}/3) model={getattr(self, 'model_id', '?')} "
11869
+ f"reply[:300]={(msg.content or '')[:300]!r}")
11829
11870
  if self._parse_fail_streak >= 3:
11830
11871
  _log("parse-fail streak hit limit → ending the turn honestly "
11831
11872
  "instead of looping on an unparseable tool call")
package/gu-repl.mjs CHANGED
@@ -61,6 +61,7 @@ import {
61
61
  thoughtFull,
62
62
  thoughtBlockRows,
63
63
  ragSegmentLabel,
64
+ ragBarNote,
64
65
  searchSegmentLabel,
65
66
  runningDetailRows,
66
67
  formatThinkTime,
@@ -2276,9 +2277,13 @@ const ragSegmentText = () => {
2276
2277
  // RAG FAILED MID-TURN and searches were answered by the grep fallback: a WARNING (yellow),
2277
2278
  // always visible — the turn still got results, but not semantic ones.
2278
2279
  const liveFallback = b.ragState === "fallback" && !b.stale;
2279
- if (!REPL_VERBOSE && !liveDown && !liveFallback) return lexSeg;
2280
+ // Task #34: a LIVE grep fallback (embedder down, or RAG failed mid-turn) is no longer on this
2281
+ // row. In red with a "✗" it read as a serious fault while search kept working. It is now a soft
2282
+ // yellow note on the bottom bar's mode row (ragBarNote → footerRows' modeNote).
2283
+ if (liveDown || liveFallback) return lexSeg;
2284
+ if (!REPL_VERBOSE) return lexSeg;
2280
2285
  if (b.stale) return dim(" · ") + dim(`${label}·`) + lexSeg;
2281
- return dim(" · ") + (liveDown ? red(label) : liveFallback ? yellow(label) : dim(label)) + lexSeg;
2286
+ return dim(" · ") + dim(label) + lexSeg;
2282
2287
  };
2283
2288
  /**
2284
2289
  * IS ANYTHING NEWER? (task #181) — cached, never probed from a repaint.
@@ -2729,6 +2734,10 @@ const footerRows = () =>
2729
2734
  identity: identityLineText(),
2730
2735
  token: tokenLineText(),
2731
2736
  mode: modeLineText(),
2737
+ // Task #34: "rag: embedder down → grep fallback" as a soft yellow note beside the mode, not an
2738
+ // alarming red segment on the token row. yellow goes through paint(), so NO_COLOR turns it off.
2739
+ modeNote: ragBarNote(lastPromptBreakdown),
2740
+ notePaint: yellow,
2732
2741
  // How to paste here (issue #67): the platform's text key, plus gu's image key when the coder
2733
2742
  // can take an image.
2734
2743
  modeHint: pasteHint({ images: coderTakesImages() }),
@@ -3540,6 +3549,7 @@ function printThoughtBlock(raw, before, { gap = false } = {}) {
3540
3549
  collapsed: true,
3541
3550
  lines: THOUGHT_COLLAPSED_LINES,
3542
3551
  });
3552
+ const rowsBeforeMerge = screenRows().length;
3543
3553
  const newEnd = transcript.replaceRange(prev.from, prev.to, grown.map((r) => GUTTER + dim(r)));
3544
3554
  if (newEnd !== null) {
3545
3555
  prev.prose = merged;
@@ -3548,7 +3558,7 @@ function printThoughtBlock(raw, before, { gap = false } = {}) {
3548
3558
  // move under the pointer, so the cached hover row must be dropped — same reason
3549
3559
  // toggleThoughtBlock does it after a resize of the block.
3550
3560
  hoverCtl.reset();
3551
- paintLiveTail();
3561
+ repaintTailOrHoldView(rowsBeforeMerge); // #113/#35: a finished thought never pulls a reader down
3552
3562
  return true;
3553
3563
  }
3554
3564
  // Scrolled out of the buffer — fall through and print a new block rather than lose it.
@@ -3855,6 +3865,21 @@ function toggleNewestThought() {
3855
3865
  return toggleThoughtBlock(block);
3856
3866
  }
3857
3867
 
3868
+ /**
3869
+ * #113 / #35 — A BLOCK AT THE TAIL CHANGED SIZE (a thought merged, a step group grew). At the
3870
+ * live tail that is a paintLiveTail. Scrolled back, it is NOT: paintLiveTail sets scrollOffset
3871
+ * to 0, which is exactly the "it finished thinking and threw me back to the bottom" report. The
3872
+ * change happened below (or at the edge of) what the reader is looking at, so keep the same
3873
+ * lines on screen — the offset moves by the rows added — and let the jump notice count up.
3874
+ */
3875
+ function repaintTailOrHoldView(rowsBefore) {
3876
+ if (barPinned() && scrolledBack()) {
3877
+ scrollOffset = Math.max(1, scrollOffset + (screenRows().length - rowsBefore));
3878
+ renderScrollback(); // same slice as before; ends in paintBar (the "N lines below" count)
3879
+ return;
3880
+ }
3881
+ paintLiveTail();
3882
+ }
3858
3883
  /** Back to the live tail: repaint it from the transcript and hand the screen back. */
3859
3884
  function jumpToBottom() {
3860
3885
  if (!barPinned() || !scrollOffset) return; // already live — nothing to come back from
@@ -11876,13 +11901,16 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
11876
11901
  if (stepGroupStillOpen()) {
11877
11902
  stepGroup.items.push(text);
11878
11903
  const painted = paintThoughtRows(stepGroup, hoverCtl.hovered === stepGroup);
11904
+ const rowsBeforeStep = screenRows().length;
11879
11905
  const newEnd = transcript.replaceRange(stepGroup.from, stepGroup.to, painted);
11880
11906
  if (newEnd !== null) {
11881
11907
  const delta = newEnd - stepGroup.to;
11882
11908
  stepGroup.to = newEnd;
11883
11909
  if (delta === 0) {
11884
11910
  // Collapsed: one row before, one row after — rewrite that row and nothing else.
11885
- ui(thoughtBlockRepaintSeq(stepGroup));
11911
+ // Scrolled back (#113/#35): those rows are live-tail coordinates, NOT the history the
11912
+ // reader is looking at — painting them would draw over it. The transcript has it.
11913
+ if (!scrolledBack()) ui(thoughtBlockRepaintSeq(stepGroup));
11886
11914
  paintBar();
11887
11915
  } else {
11888
11916
  // Open (the user clicked it mid-turn): it grew, and what is under it moved down.
@@ -11893,7 +11921,7 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
11893
11921
  }
11894
11922
  }
11895
11923
  hoverCtl.reset();
11896
- paintLiveTail();
11924
+ repaintTailOrHoldView(rowsBeforeStep); // #113/#35: never pull a reader down
11897
11925
  }
11898
11926
  return;
11899
11927
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.812",
3
+ "version": "1.0.813",
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",
@@ -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 ✗ embedder down → grep fallback";
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 ⚠ unavailable → grep fallback";
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,34 @@ 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
+ /** Cut plain text to `room` visible columns, ending with "…" when it was cut. */
202
+ function fitPlain(s, room) {
203
+ s = String(s ?? "");
204
+ if (visibleLen(s) <= room) return s;
205
+ if (room <= 1) return room === 1 ? "…" : "";
206
+ let out = "";
207
+ for (const ch of s) {
208
+ if (visibleLen(out + ch) > room - 1) break;
209
+ out += ch;
210
+ }
211
+ return out + "…";
212
+ }
213
+
185
214
  // The six origin buckets. They are scaled to sum to the server's prompt_tokens for ONE
186
215
  // request, so their sum IS the size of the newest context — the number to compare against
187
216
  // the model's window. Deliberately excludes ragFresh/ragCarried (shares OF rag, not extra)
@@ -544,11 +573,25 @@ export function buildFooterRows({
544
573
  // Right-aligned on the mode row (issue #67), dim. DROPPED WHOLE when it does not fit: every
545
574
  // footer row must stay one physical row (#109/#132/#138), and half a key name is worse than none.
546
575
  modeHint = "",
576
+ // Task #34: a soft notice (PLAIN text) after the mode text, painted with notePaint. It takes
577
+ // priority over the paste hint and is cut with "…" on a narrow window, never wrapped.
578
+ modeNote = "",
579
+ notePaint = dim,
547
580
  notice = "",
548
581
  missing = "",
549
582
  }) {
550
583
  const w = Math.max(20, width || 80);
551
- const modeLeft = gutter + mode;
584
+ let modeLeft = gutter + mode;
585
+ if (modeNote) {
586
+ const NOTE_GAP = " ";
587
+ const hintRoom = modeHint ? HINT_MIN_GAP + visibleLen(modeHint) : 0;
588
+ let room = w - 1 - visibleLen(modeLeft) - NOTE_GAP.length;
589
+ // Keep the paste hint only when the whole notice still fits beside it.
590
+ if (modeHint && visibleLen(modeNote) <= room - hintRoom) room -= hintRoom;
591
+ else modeHint = "";
592
+ // Too little room to say anything readable: leave the notice out.
593
+ if (room >= 8) modeLeft += NOTE_GAP + notePaint(fitPlain(modeNote, room));
594
+ }
552
595
  const hintGap = modeHint ? w - 1 - visibleLen(modeLeft) - visibleLen(modeHint) : -1;
553
596
  const modeRow =
554
597
  hintGap >= HINT_MIN_GAP