@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.
@@ -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
- if (_interactive_approval and _job_id and worker_api_base and worker_api_key
10579
- and _compact_state["decision"] != "never"):
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
- if (!REPL_VERBOSE && !liveDown && !liveFallback) return lexSeg;
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(" · ") + (liveDown ? red(label) : liveFallback ? yellow(label) : dim(label)) + lexSeg;
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
- paintLiveTail();
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
- ui(thoughtBlockRepaintSeq(stepGroup));
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
- paintLiveTail();
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.812",
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",
@@ -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,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
- const modeLeft = gutter + mode;
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";