@tiens.nguyen/gu-cli 1.0.787 → 1.0.789

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/gu-repl.mjs CHANGED
@@ -63,6 +63,11 @@ import {
63
63
  ragSegmentLabel,
64
64
  searchSegmentLabel,
65
65
  runningDetailRows,
66
+ runningCommandLabel,
67
+ runningCommandRows,
68
+ dropRestatement,
69
+ isRestatement,
70
+ liveThoughtText,
66
71
  formatThinkTime,
67
72
  plainAnswer,
68
73
  summarizeError,
@@ -128,6 +133,7 @@ import {
128
133
  } from "./terminal-layout.mjs";
129
134
  import { completedFlush, splitLiveStream } from "./completed-flush.mjs";
130
135
  import { isScaffoldStep, isGroupableStep, stepGroupRows, relativizePaths } from "./step-group.mjs";
136
+ import { clearLogsNoticeText } from "./clear-logs.mjs";
131
137
  import { discoverSkills, matchSkillCommand } from "./skills.mjs";
132
138
  import { appendNew, archivePathFor, archivedWorkspaceKey, markReset, seedArchive } from "./conversation-archive.mjs";
133
139
  import { addAllowRule, listServers as listMcpServers } from "./mcp-config.mjs";
@@ -891,6 +897,7 @@ const COMMANDS = [
891
897
  { name: "/use-presets", usage: "/use-presets [name]", desc: "pick a saved preset and apply it — shows what changes, backs up your setup, asks first" },
892
898
  { name: "/doctor", desc: "check every configured model ANSWERS — especially the embedder, which a turn only reaches after several steps" },
893
899
  { name: "/tokens", desc: "break down the last prompt sent to the coding model — how much of it was RAG" },
900
+ { name: "/clear-logs", desc: "show what gu's logs, RAG indexes and old API versions take on disk, then ask what to clear — the logs first" },
894
901
  { name: "/prices", usage: "/prices [update]", desc: "the price table and the cache table, and what the last turn and this session cost — \"update\" applies gu's newest published tables" },
895
902
  // ONE name, /new (user, #278). /reset and /clear still work (HIDDEN_COMMANDS) for habit and
896
903
  // for Claude Code users — not to be confused with `gu clear` in a shell, which wipes gu's state.
@@ -1462,6 +1469,11 @@ function applyClick({ row, col }) {
1462
1469
  void runPricesUpdate();
1463
1470
  return;
1464
1471
  }
1472
+ if (logsRowTarget && row === updateRowAbs &&
1473
+ col - 1 >= logsRowTarget.from && col - 1 < logsRowTarget.to) {
1474
+ void runClearLogs();
1475
+ return;
1476
+ }
1465
1477
  if (agentsRowTarget && row === updateRowAbs &&
1466
1478
  col - 1 >= agentsRowTarget.from && col - 1 < agentsRowTarget.to) {
1467
1479
  void showOtherAgents(); // async now — it offers stop / switch-to (task #211)
@@ -2680,6 +2692,9 @@ function zoneNotice(width) {
2680
2692
  if (tokenNotice?.text) items.push({ key: "token", paint: tokenNotice.urgent ? yellow : dim, text: tokenNotice.text });
2681
2693
  const agents = agentsIndicator(otherAgents);
2682
2694
  if (agents) items.push({ key: "agents", clickable: true, paint: accent, text: `${agents} running (click)` });
2695
+ // LAST, so it is the first to give way on a narrow window: housekeeping, never urgent (#329).
2696
+ const logs = clearLogsNoticeText(logsBytes);
2697
+ if (logs) items.push({ key: "logs", clickable: true, paint: dim, text: logs });
2683
2698
  return noticeRow(items, { width, gutter: GUTTER });
2684
2699
  }
2685
2700
 
@@ -2760,6 +2775,7 @@ function frozenZone() {
2760
2775
  updateRowTarget = notice?.spans.update ?? null;
2761
2776
  agentsRowTarget = notice?.spans.agents ?? null;
2762
2777
  pricesRowTarget = notice?.spans.prices ?? null;
2778
+ logsRowTarget = notice?.spans.logs ?? null;
2763
2779
  jumpSpan = notice?.spans.jump ?? null;
2764
2780
  return { ...zone, rows, statusRows: sep, noticeRow: Boolean(notice) };
2765
2781
  }
@@ -2829,7 +2845,7 @@ function paintBar() {
2829
2845
  // THE NOTICE ROW is the one directly above the separator (#318) — never the separator itself.
2830
2846
  const noticeAbs = zone.noticeRow ? sepAbs - 1 : 0;
2831
2847
  jumpRowTarget = scrolledBack() && jumpSpan ? { row: noticeAbs, ...jumpSpan } : null;
2832
- updateRowAbs = updateRowTarget || agentsRowTarget || pricesRowTarget ? noticeAbs : 0;
2848
+ updateRowAbs = updateRowTarget || agentsRowTarget || pricesRowTarget || logsRowTarget ? noticeAbs : 0;
2833
2849
  // preserveCaret:false — the save slot holds the OUTPUT position (see regionUi), and the caret
2834
2850
  // is parked on the input line right after, so there is nothing to preserve here.
2835
2851
  ui(
@@ -2991,6 +3007,8 @@ let updateRowAbs = 0;
2991
3007
  let agentsRowTarget = null;
2992
3008
  // …and "new prices updated <date>" (#323).
2993
3009
  let pricesRowTarget = null;
3010
+ // …and "clear logs · 175 MB" (#329), shown only once the logs are worth a row.
3011
+ let logsRowTarget = null;
2994
3012
  // A terminal left with a scrolling region is a broken shell — a hard guarantee, like the
2995
3013
  // bracketed-paste and mouse-reporting teardowns.
2996
3014
  process.on("exit", () => {
@@ -3301,7 +3319,33 @@ const paintThoughtRows = (block, hovered, collapsed = block.collapsed) => {
3301
3319
  collapsed,
3302
3320
  lines: THOUGHT_COLLAPSED_LINES,
3303
3321
  });
3304
- return rows.map((r) => GUTTER + paint(r));
3322
+ return (block.lead ? [""] : []).concat(rows.map((r) => THOUGHT_GUTTER + paint(r)));
3323
+ };
3324
+
3325
+ /**
3326
+ * A THOUGHT HAS ITS OWN MARK AND ITS OWN ROOM (task #334).
3327
+ *
3328
+ * Reported with a screenshot: "● listed files · read file 7×" and, flush under it in the same
3329
+ * grey at the same indent, "The user wants me to use the .env file…" — which read as the rest of
3330
+ * the bullet. Two things were missing, both since #326 made the look-ups ONE row that stays where
3331
+ * it is while thoughts print under it (before that a blank or the question always sat above):
3332
+ *
3333
+ * THE MARK — a dim rule down the margin, on every row of the block, so two or twenty rows read
3334
+ * as one thing and never as an action ("●" means something was DONE). It takes the gutter's own
3335
+ * three cells, so no row gets wider and nothing that measures rows changes.
3336
+ *
3337
+ * THE BLANK ROW — owned by the BLOCK (`lead`), not written beside it. The open group's row is
3338
+ * rewritten in place and everything under it is found by row arithmetic that expects the blocks
3339
+ * to touch (stepGroupStillOpen, the merge below, the shift when a block opens); a blank that
3340
+ * belonged to nobody would break each of those. As the block's first row it moves, repaints
3341
+ * and is replaced with the block.
3342
+ */
3343
+ const THOUGHT_GUTTER = " " + dim("│") + " ";
3344
+
3345
+ /** Is the last finished line of the transcript empty (so a block printed now needs no blank)? */
3346
+ const transcriptEndsBlank = () => {
3347
+ const lines = transcript.lines();
3348
+ return lines.length < 2 || stripAnsiEscapes(lines[lines.length - 2] ?? "").trim() === "";
3305
3349
  };
3306
3350
 
3307
3351
  /** Width available to a thought row: the screen, less the gutter it hangs in. */
@@ -3317,7 +3361,9 @@ const thoughtWidth = () =>
3317
3361
  */
3318
3362
  function printThoughtBlock(raw, before) {
3319
3363
  if (!sessionShowThought) return false;
3320
- const prose = thoughtProse(raw);
3364
+ // The question said back ("The user wants me to…") is dropped from the front (#333): it sits
3365
+ // directly under the question, and it was all the two collapsed rows had room for.
3366
+ const prose = dropRestatement(thoughtProse(raw), turnQuestionText);
3321
3367
  if (!prose) return false; // a bare tool label or pure code — not what anyone means by a thought
3322
3368
  const rows = thoughtBlockRows(prose, {
3323
3369
  width: thoughtWidth(),
@@ -3344,12 +3390,7 @@ function printThoughtBlock(raw, before) {
3344
3390
  const prev = thoughtBlocks[thoughtBlocks.length - 1];
3345
3391
  if (prev && !prev.kind && prev.collapsed && prev.to === transcript.dropped() + transcript.length() - 2) {
3346
3392
  const merged = prev.prose.replace(/\s+$/, "") + " " + prose;
3347
- const grown = thoughtBlockRows(merged, {
3348
- width: thoughtWidth(),
3349
- collapsed: true,
3350
- lines: THOUGHT_COLLAPSED_LINES,
3351
- });
3352
- const newEnd = transcript.replaceRange(prev.from, prev.to, grown.map((r) => GUTTER + dim(r)));
3393
+ const newEnd = transcript.replaceRange(prev.from, prev.to, paintThoughtRows({ ...prev, prose: merged }, false));
3353
3394
  if (newEnd !== null) {
3354
3395
  prev.prose = merged;
3355
3396
  prev.to = newEnd;
@@ -3362,11 +3403,16 @@ function printThoughtBlock(raw, before) {
3362
3403
  }
3363
3404
  // Scrolled out of the buffer — fall through and print a new block rather than lose it.
3364
3405
  }
3365
- process.stdout.write(rows.map((r) => GUTTER + dim(r)).join("\n") + "\n");
3406
+ // Its own blank row when what is above is not one — see THOUGHT_GUTTER.
3407
+ const block = { from: 0, to: 0, prose, collapsed: true, lead: !transcriptEndsBlank() };
3408
+ const painted = paintThoughtRows(block, false);
3409
+ process.stdout.write(painted.join("\n") + "\n");
3366
3410
  // Record where it landed. Measured AFTER the write so a trim during it cannot skew the
3367
3411
  // range: the last completed line is length-2 (the final entry is the still-open line).
3368
3412
  const endAbs = transcript.dropped() + transcript.length() - 2;
3369
- thoughtBlocks.push({ from: endAbs - rows.length + 1, to: endAbs, prose, collapsed: true });
3413
+ block.from = endAbs - painted.length + 1;
3414
+ block.to = endAbs;
3415
+ thoughtBlocks.push(block);
3370
3416
  if (thoughtBlocks.length > 200) thoughtBlocks = thoughtBlocks.slice(-200);
3371
3417
  return true;
3372
3418
  }
@@ -7879,6 +7925,128 @@ async function runPricesUpdate() {
7879
7925
  }
7880
7926
  }
7881
7927
 
7928
+ // ---------------------------------------------------------------------------------------------
7929
+ // "CLEAR LOGS" (#329): a quiet item at the right of the notice row once gu's logs are worth a row,
7930
+ // and /clear-logs for where there is no mouse. A click deletes nothing: it MEASURES — the logs,
7931
+ // and beside them the two things that are actually big, the RAG indexes and the downloaded API
7932
+ // versions — shows that with a total, then asks about each. What each "clear" may and may not
7933
+ // touch is decided in clear-logs.mjs; this is the terminal half only.
7934
+ // ---------------------------------------------------------------------------------------------
7935
+ let logsBytes = 0; // what the notice shows; only the logs are measured for it (a handful of stats)
7936
+ let clearLogsBusy = false;
7937
+
7938
+ async function refreshLogsSize() {
7939
+ try {
7940
+ const { surveyLogs } = await import("./clear-logs.mjs");
7941
+ const was = clearLogsNoticeText(logsBytes);
7942
+ logsBytes = (await surveyLogs()).bytes;
7943
+ if (clearLogsNoticeText(logsBytes) !== was && barPinned()) paintBar();
7944
+ } catch {
7945
+ /* a housekeeping notice is never worth an error */
7946
+ }
7947
+ }
7948
+
7949
+ async function runClearLogs() {
7950
+ // ONE AT A TIME, and never over a turn or another picker — reached from a fire-and-forget click
7951
+ // (see showOtherAgents for what a second picker over the first one does).
7952
+ if (clearLogsBusy || listPickerActive || approvalActive || following) return;
7953
+ clearLogsBusy = true;
7954
+ try {
7955
+ const cl = await import("./clear-logs.mjs");
7956
+ const { LOCAL_API_PORT } = await import("./client-expert-api.mjs");
7957
+ const width = Math.max(20, process.stdout.columns || 80);
7958
+ console.log("\n" + GUTTER + dim("Measuring ~/.gonext…"));
7959
+ // Which API version is ANSWERING decides what "old" means — ask it, do not infer it.
7960
+ const runningVersion = await fetch(`http://127.0.0.1:${LOCAL_API_PORT}/api/health`, { signal: AbortSignal.timeout(2000) })
7961
+ .then((r) => r.json())
7962
+ .then((j) => String(j?.version ?? ""))
7963
+ .catch(() => "");
7964
+ const survey = await cl.surveyDisk({ runningVersion });
7965
+ console.log("");
7966
+ for (const row of cl.summaryRows(survey, { width: width - GUTTER.length - 1 })) {
7967
+ console.log(GUTTER + (row.startsWith("Total") ? bold(row) : row));
7968
+ }
7969
+ const freed = [];
7970
+ // A question never wraps: at 80 columns over SSH the picker hint goes on a row of its own.
7971
+ const question = (q) => {
7972
+ const hint = `(${PICK_HINT}):`;
7973
+ console.log("");
7974
+ if (2 + q.length + 2 + hint.length < width) console.log(dim(` ${q} ${hint}`));
7975
+ else console.log(dim(` ${q}\n ${hint}`));
7976
+ };
7977
+ const cancelled = () => {
7978
+ console.log(dim(` (stopped — nothing ${freed.length ? "further " : ""}removed)\n`));
7979
+ paintBar();
7980
+ };
7981
+
7982
+ // 1. THE LOGS — what was asked for, so Yes is the first row.
7983
+ if (survey.logs.bytes > 0) {
7984
+ question(`Clear the logs — ${cl.humanSize(survey.logs.bytes)}?`);
7985
+ const go = await pickFromList(["Yes — clear the logs" + dim(" · the worker and the API keep running"), "No"], 0);
7986
+ if (go < 0) return cancelled();
7987
+ if (go === 0) {
7988
+ const r = await cl.clearLogs(survey.logs);
7989
+ freed.push({ what: "logs", ...r });
7990
+ console.log(GUTTER + accent(`✓ logs cleared — ${cl.humanSize(r.freed)} freed`) +
7991
+ (r.failed.length ? yellow(` (still in use, left alone: ${r.failed.join(", ")})`) : ""));
7992
+ } else {
7993
+ console.log(GUTTER + dim("logs kept"));
7994
+ }
7995
+ } else {
7996
+ console.log("\n" + GUTTER + dim("No logs to clear."));
7997
+ }
7998
+
7999
+ // 2. RAG — costs a re-index, so No is the first row.
8000
+ if (survey.rag.indexes.length) {
8001
+ const gone = survey.rag.indexes.filter((i) => i.missing);
8002
+ const goneBytes = gone.reduce((n, i) => n + i.bytes, 0);
8003
+ const choices = [{ id: "no", label: "No — keep them" }];
8004
+ if (gone.length && gone.length < survey.rag.indexes.length) {
8005
+ choices.push({ id: "gone", label: `Only the ${gone.length} whose folder no longer exists — ${cl.humanSize(goneBytes)}` });
8006
+ }
8007
+ choices.push({ id: "all", label: `Yes — all ${survey.rag.indexes.length}` + dim(" · a folder is indexed again when it is next used") });
8008
+ question(`Also clear the RAG indexes — ${cl.humanSize(survey.rag.bytes)}?`);
8009
+ const pick = await pickFromList(choices.map((c) => c.label), 0);
8010
+ if (pick < 0) return cancelled();
8011
+ if (choices[pick].id !== "no") {
8012
+ const r = await cl.clearRagIndexes(choices[pick].id === "gone" ? gone : survey.rag.indexes);
8013
+ freed.push({ what: "rag", ...r });
8014
+ console.log(GUTTER + accent(`✓ ${r.removed} RAG index${r.removed === 1 ? "" : "es"} cleared — ${cl.humanSize(r.freed)} freed`) +
8015
+ (r.failed.length ? yellow(` (could not remove: ${r.failed.join(", ")})`) : ""));
8016
+ } else {
8017
+ console.log(GUTTER + dim("RAG indexes kept"));
8018
+ }
8019
+ }
8020
+
8021
+ // 3. THE API — only ever the OLDER versions; the one in use is not on offer.
8022
+ if (survey.api.old.length) {
8023
+ const n = survey.api.old.length;
8024
+ question(`Also remove the ${n} older API version${n === 1 ? "" : "s"} — ${cl.humanSize(survey.api.oldBytes)}?`);
8025
+ const go = await pickFromList(["No — keep them", `Yes — remove the ${n} older one${n === 1 ? "" : "s"}` +
8026
+ dim(survey.api.inUse ? ` · ${survey.api.inUse}, the one in use, stays` : "")], 0);
8027
+ if (go < 0) return cancelled();
8028
+ if (go === 1) {
8029
+ console.log(GUTTER + dim("Removing…"));
8030
+ const r = await cl.clearOldApiVersions(survey.api.old);
8031
+ freed.push({ what: "api", ...r });
8032
+ console.log(GUTTER + accent(`✓ ${r.removed} older API version${r.removed === 1 ? "" : "s"} removed — ${cl.humanSize(r.freed)} freed`) +
8033
+ (r.failed.length ? yellow(` (could not remove: ${r.failed.join(", ")})`) : ""));
8034
+ } else {
8035
+ console.log(GUTTER + dim("older API versions kept"));
8036
+ }
8037
+ }
8038
+
8039
+ const total = freed.reduce((n, f) => n + f.freed, 0);
8040
+ console.log("\n" + GUTTER + (freed.length ? bold(`${cl.humanSize(total)} freed in all.`) : dim("Nothing removed.")) + "\n");
8041
+ } catch (e) {
8042
+ console.log(GUTTER + red(`couldn't finish — ${e?.message ?? e}`) + "\n");
8043
+ } finally {
8044
+ clearLogsBusy = false;
8045
+ await refreshLogsSize();
8046
+ if (barPinned()) paintBar();
8047
+ }
8048
+ }
8049
+
7882
8050
  /**
7883
8051
  * `/embed-model` — switch the RAG embedder, the embed server, or the embed key (task #169).
7884
8052
  *
@@ -9708,6 +9876,10 @@ const RAGSTATE_PREFIX = "__RAGSTATE__::";
9708
9876
  // The tool ABOUT to run: "<label>\t<call>" (task #177). Swallowed from scrollback like
9709
9877
  // "Running → …" — it belongs to the live block, and the finished bullet replaces it.
9710
9878
  const RUNTOOL_PREFIX = "__RUNTOOL__::";
9879
+ // The command that is running, WHOLE (task #331): one line of JSON {cmd}, sent beside the
9880
+ // 70-character "Running → …" line — and how many lines it has printed so far. Both swallowed.
9881
+ const RUNCMD_PREFIX = "__RUNCMD__::";
9882
+ const RUNOUT_PREFIX = "__RUNOUT__::";
9711
9883
  const MAXSTEP_PREFIX = "__MAXSTEP__::";
9712
9884
  const TIMEOUT_PREFIX = "__TIMEOUT__::";
9713
9885
  const COMPACT_PREFIX = "__COMPACT__::";
@@ -10624,7 +10796,25 @@ async function askTokenAtStart() {
10624
10796
  /** The token refresh started at launch — askTokenAtStart waits for it (see there). */
10625
10797
  let startupTokenRefresh = null;
10626
10798
 
10799
+ /**
10800
+ * The fuller live thought (what the status row is driven from once it is longer than the row),
10801
+ * held to the same rule as the short one (#333): from its first real sentence, without the
10802
+ * question or gu's own instructions said back — "" while that is all there is so far.
10803
+ */
10804
+ const liveThoughtClean = (text) => {
10805
+ const kept = dropRestatement(text, turnQuestionText);
10806
+ return isRestatement(kept, turnQuestionText) ? "" : liveThoughtText(kept);
10807
+ };
10808
+
10809
+ // The question the running turn is answering — what a thought is compared with so that the
10810
+ // question said back is not printed under the question (#333). Display only.
10811
+ let turnQuestionText = "";
10812
+
10627
10813
  async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {}) {
10814
+ {
10815
+ const asked = [...(Array.isArray(history) ? history : [])].reverse().find((m) => m?.role === "user");
10816
+ turnQuestionText = typeof asked?.content === "string" ? asked.content : "";
10817
+ }
10628
10818
  // #238: when the licence is ENFORCED and this account is not entitled, no turn is sent. Decided at
10629
10819
  // startup (see licenceBlock) — never re-checked here, so a turn cannot die halfway through.
10630
10820
  if (licenceBlock) {
@@ -10842,6 +11032,12 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
10842
11032
  let lastWordBucket = -1; // 12s window index → rotate the word once per window (task: 30-spinner)
10843
11033
  let almostDone = false; // set from the worker's "…almost completed thinking…" heartbeat
10844
11034
  let runningCmd = null; // the command currently executing (from a "Running → …" event)
11035
+ // …the same command WHOLE, what it has printed so far, when it started, and whether the status
11036
+ // row is hiding some of it (i.e. a click has something to open) — task #331.
11037
+ let runningCmdFull = "";
11038
+ let runningCmdLines = 0;
11039
+ let runningCmdAt = 0;
11040
+ let runningCmdMore = false;
10845
11041
  let runningTool = null; // { label, detail } for the tool executing right now (task #177)
10846
11042
  // The model's CURRENT Thought (task #64): while it streams its reasoning inside the
10847
11043
  // hidden ~~~ fence, show a one-clause summary on the blinking line so a 300s CPU-bound
@@ -10861,6 +11057,28 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
10861
11057
  // became of it. A no-code retry notice discards it (the model restates the plan on the retry);
10862
11058
  // anything else prints it. Always null under --verbose, which shows everything as it comes.
10863
11059
  let heldThought = null;
11060
+ // A THOUGHT INTERRUPTED BY THE HEARTBEAT IS STILL ONE THOUGHT (#333). The worker's
11061
+ // "…almost completed thinking… (180s)" is a step, and a step closes the stream fence — in the
11062
+ // MIDDLE of the model's sentence. The fence then reopens and the rest of the sentence arrived
11063
+ // as a new thought: the live line read `one, call use_skill(name) FIRST…` and
11064
+ // `` `, `bootstrap-api-service.mjs` — add… `` (both from stored streams), and the history got
11065
+ // the thought in two halves. So a closing fence is only PARKED here: if nothing but blanks and
11066
+ // heartbeats follow and the fence opens again, the thought carries on where it stopped;
11067
+ // anything else finishes it exactly as before.
11068
+ let pausedFence = null; // { step, fence } while a fence is closed but not yet finished
11069
+ const finishPausedFence = () => {
11070
+ if (!pausedFence) return;
11071
+ const step = pausedFence.step;
11072
+ pausedFence = null;
11073
+ // The step's model output is complete. Print its reasoning HERE, before the tools
11074
+ // report what they did, so the transcript reads in the order it happened: the model
11075
+ // says what it intends, then the actions it took appear under it.
11076
+ if (!REPL_VERBOSE && step.trim() && !stepHasCode(step)) {
11077
+ heldThought = step; // #45: decided by the next line — see releaseHeldThought
11078
+ } else if (printThoughtBlock(step, onContent)) {
11079
+ lastWasBlank = false;
11080
+ }
11081
+ };
10864
11082
  const releaseHeldThought = () => {
10865
11083
  if (heldThought === null) return;
10866
11084
  const raw = heldThought;
@@ -11062,9 +11280,23 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
11062
11280
  // to reveal). Line 1 (what's happening now): a live command > the model's current Thought >
11063
11281
  // "almost done" > "Thinking". When clickable we drive line 1 from the SAME fuller string, so
11064
11282
  // the grey expansion CONTINUES where line 1 was cut off instead of repeating the head.
11065
- const clickable = !!fullLiveThought && !runningCmd && fullLiveThought.length > max;
11283
+ // A RUNNING COMMAND is its own kind of line (#331): "$ <first line>… (17s · 5 lines)" on one
11284
+ // row, and a click opens the whole command underneath instead of a thought's remainder.
11285
+ const cmdView = runningCmd && !runningTool
11286
+ ? runningCommandLabel(runningCmdFull || runningCmd, {
11287
+ room: max,
11288
+ secs: runningCmdAt ? Math.max(0, Math.round((now - runningCmdAt) / 1000)) : secs,
11289
+ lines: runningCmdLines,
11290
+ expanded: thoughtExpanded,
11291
+ })
11292
+ : null;
11293
+ runningCmdMore = Boolean(cmdView?.more);
11294
+ const clickable = cmdView ? cmdView.more : !!fullLiveThought && !runningCmd && fullLiveThought.length > max;
11066
11295
  let label, rest;
11067
- if (clickable) {
11296
+ if (cmdView) {
11297
+ label = cmdView.label;
11298
+ rest = "";
11299
+ } else if (clickable) {
11068
11300
  // Split at a WORD boundary near the line-1 width so nothing is cut mid-word: the last
11069
11301
  // space at/before max-1 (unless that snaps back too far into the line — then hard-cut).
11070
11302
  // Line 1 shows the head; the grey expansion CONTINUES from the same point (no dup, no
@@ -11084,7 +11316,13 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
11084
11316
  }
11085
11317
  // When clicked, unfold the remainder as up to 4 wrapped GREY lines BETWEEN the thought and
11086
11318
  // the playful-word line.
11087
- const expLines = thoughtExpanded && rest ? wrapThought(rest, max, 4) : [];
11319
+ const expLines = cmdView
11320
+ ? thoughtExpanded
11321
+ ? runningCommandRows(runningCmdFull || runningCmd, {
11322
+ width: Math.max(20, process.stdout.columns || 80) - 1 - GUTTER.length,
11323
+ })
11324
+ : []
11325
+ : thoughtExpanded && rest ? wrapThought(rest, max, 4) : [];
11088
11326
  // WHAT IS RUNNING, under the label (task #177). Capped at two rows and clipped, because
11089
11327
  // this block is redrawn by walking the caret over PHYSICAL rows — a wrapped row here is
11090
11328
  // the #109/#138 failure mode.
@@ -11215,14 +11453,14 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
11215
11453
  if (row < top || row > top + statusContentLines - LEAD_ROWS - WORD_GAP - 2) return;
11216
11454
  // Only meaningful when there IS a fuller thought to reveal; otherwise ignore the click
11217
11455
  // so we don't flicker an empty expand.
11218
- if (!thoughtExpanded && !(fullLiveThought && !runningCmd)) return;
11456
+ if (!thoughtExpanded && !(runningCmd ? runningCmdMore : fullLiveThought)) return;
11219
11457
  toggleThought();
11220
11458
  };
11221
11459
  // Same expand/collapse, reachable without the mouse: Ctrl+T while a turn runs (see
11222
11460
  // toggleActiveThought). Ignored when there's nothing more to reveal, so it never flickers an
11223
11461
  // empty expansion.
11224
11462
  const toggleThought = () => {
11225
- if (!thoughtExpanded && !(fullLiveThought && !runningCmd)) return;
11463
+ if (!thoughtExpanded && !(runningCmd ? runningCmdMore : fullLiveThought)) return;
11226
11464
  thoughtExpanded = !thoughtExpanded;
11227
11465
  clearStatus(); // next tick (≤120ms) redraws at the new size; keeps cursor math correct
11228
11466
  };
@@ -11258,6 +11496,9 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
11258
11496
  clearStatus();
11259
11497
  thinking = false;
11260
11498
  runningCmd = null;
11499
+ runningCmdFull = "";
11500
+ runningCmdLines = 0;
11501
+ runningCmdAt = 0;
11261
11502
  runningTool = null;
11262
11503
  // A concrete action landed → the Thought that led to it is spent; clear it so the
11263
11504
  // next step's blinking line starts from "Thinking", not last step's stale clause.
@@ -11381,6 +11622,9 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
11381
11622
  almostDone = /almost/i.test(line);
11382
11623
  continue;
11383
11624
  }
11625
+ // A parked fence (see pausedFence) is finished by the first real line that is not the
11626
+ // fence opening again.
11627
+ if (pausedFence && line.trim() !== "" && !isFenceLine(line)) finishPausedFence();
11384
11628
  // #45: retry notices and context-window bookkeeping are the agent's own machinery. Shown
11385
11629
  // under --verbose only; otherwise swallowed, together with the no-code false start the
11386
11630
  // retry notice is about.
@@ -11405,6 +11649,20 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
11405
11649
  lastContentAt = Date.now();
11406
11650
  continue;
11407
11651
  }
11652
+ if (line.startsWith(RUNCMD_PREFIX)) {
11653
+ try {
11654
+ runningCmdFull = String(JSON.parse(line.slice(RUNCMD_PREFIX.length))?.cmd ?? "");
11655
+ } catch {
11656
+ runningCmdFull = ""; // the short "Running → …" line still names it
11657
+ }
11658
+ lastContentAt = Date.now();
11659
+ continue;
11660
+ }
11661
+ if (line.startsWith(RUNOUT_PREFIX)) {
11662
+ runningCmdLines = Number(line.slice(RUNOUT_PREFIX.length).trim()) || 0;
11663
+ lastContentAt = Date.now();
11664
+ continue;
11665
+ }
11408
11666
  if (line.startsWith(RUNTOOL_PREFIX)) {
11409
11667
  const [label, ...rest] = line.slice(RUNTOOL_PREFIX.length).split("\t");
11410
11668
  runningTool = { label: (label || "Working").trim(), detail: rest.join("\t").trim() };
@@ -11422,17 +11680,19 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
11422
11680
  if (isFenceLine(line)) {
11423
11681
  inStreamFence = !inStreamFence;
11424
11682
  if (inStreamFence) {
11425
- fenceThought = "";
11426
- stepThought = "";
11427
- } else {
11428
- // The step's model output is complete. Print its reasoning HERE, before the tools
11429
- // report what they did, so the transcript reads in the order it happened: the model
11430
- // says what it intends, then the actions it took appear under it.
11431
- if (!REPL_VERBOSE && stepThought.trim() && !stepHasCode(stepThought)) {
11432
- heldThought = stepThought; // #45: decided by the next line — see releaseHeldThought
11433
- } else if (printThoughtBlock(stepThought, onContent)) {
11434
- lastWasBlank = false;
11683
+ if (pausedFence) {
11684
+ // …the same thought, carrying on. The fence close put a line break where the model
11685
+ // had none ("…When the TASK matches" / " one, call…"), so the two halves are joined
11686
+ // on the line they were cut in.
11687
+ stepThought = pausedFence.step.replace(/\n$/, "");
11688
+ fenceThought = pausedFence.fence.replace(/\n$/, "");
11689
+ pausedFence = null;
11690
+ } else {
11691
+ fenceThought = "";
11692
+ stepThought = "";
11435
11693
  }
11694
+ } else {
11695
+ pausedFence = { step: stepThought, fence: fenceThought };
11436
11696
  stepThought = "";
11437
11697
  }
11438
11698
  continue;
@@ -11452,11 +11712,13 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
11452
11712
  // Accumulate a bounded head of the stream (enough to see the Thought prose OR the
11453
11713
  // tool call), capped so a 16k-char runaway can't grow the buffer. normalizeThought
11454
11714
  // yields a clean label — never raw code/CSS.
11455
- if (fenceThought.length < 400) {
11715
+ // (1200, not 400, since #333: the line is now taken from past the question said back,
11716
+ // and a restatement alone can run to a few hundred characters)
11717
+ if (fenceThought.length < 1200) {
11456
11718
  fenceThought += line + "\n";
11457
- const th = normalizeThought(fenceThought);
11719
+ const th = normalizeThought(fenceThought, { question: turnQuestionText });
11458
11720
  if (th) liveThought = th;
11459
- fullLiveThought = thoughtFull(fenceThought); // #96: keep the fuller thought for expand
11721
+ fullLiveThought = liveThoughtClean(thoughtFull(fenceThought)); // #96: keep the fuller thought for expand
11460
11722
  }
11461
11723
  continue;
11462
11724
  }
@@ -11575,7 +11837,17 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
11575
11837
  // So the pair collapses to: "◑ Running <cmd>…" while it runs, then "● <result>".
11576
11838
  if (!plainReplyFlow) {
11577
11839
  const mRun = /^Running → (.+)$/.exec(line.trim());
11578
- if (mRun) { runningCmd = mRun[1]; thinking = false; lastContentAt = Date.now(); continue; }
11840
+ if (mRun) {
11841
+ runningCmd = mRun[1];
11842
+ // a NEW command: its own clock and count, and it starts as one row (#331)
11843
+ runningCmdFull = "";
11844
+ runningCmdLines = 0;
11845
+ runningCmdAt = Date.now();
11846
+ thoughtExpanded = false;
11847
+ thinking = false;
11848
+ lastContentAt = Date.now();
11849
+ continue;
11850
+ }
11579
11851
  }
11580
11852
  // Skip the status-clear for a BUFFERED plain reply (STREAM_PLAIN_REPLY=false): nothing
11581
11853
  // prints yet, so the thinking ticker should keep animating until completion.
@@ -11658,9 +11930,9 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
11658
11930
  // live clause from the partial too, so the blinking line updates mid-sentence instead
11659
11931
  // of only once the line finally breaks (task #64).
11660
11932
  if (inStreamFence && fenceThought.length < 400) {
11661
- const th = normalizeThought(fenceThought + carry);
11933
+ const th = normalizeThought(fenceThought + carry, { question: turnQuestionText });
11662
11934
  if (th) liveThought = th;
11663
- fullLiveThought = thoughtFull(fenceThought + carry); // #96: fuller thought for expand
11935
+ fullLiveThought = liveThoughtClean(thoughtFull(fenceThought + carry)); // #96: fuller thought for expand
11664
11936
  }
11665
11937
  // A non-empty remainder is a partial CONTENT line (heartbeats always arrive whole with
11666
11938
  // a newline), so tokens are actively flowing — keep the ticker asleep. EXCEPT a buffered
@@ -11861,6 +12133,7 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
11861
12133
  lastWasBlank = false;
11862
12134
  }
11863
12135
  }
12136
+ finishPausedFence(); // #333: a fence closed and nothing followed — it is finished now
11864
12137
  releaseHeldThought(); // #45: nothing followed it, so it was not a false start
11865
12138
  clearStatus();
11866
12139
  // Task #104: expose this turn's code-model INPUT + the CUMULATIVE output-token
@@ -12803,6 +13076,8 @@ async function main() {
12803
13076
  // #323: gu's newest published price + cache tables — same cadence, same silent failure.
12804
13077
  void refreshPriceUpdate();
12805
13078
  setInterval(() => void refreshPriceUpdate(), UPDATE_CHECK_MS).unref();
13079
+ void refreshLogsSize();
13080
+ setInterval(() => void refreshLogsSize(), UPDATE_CHECK_MS).unref();
12806
13081
  // The gu token (#232): same arrangement — background, never delays the prompt, unref'd.
12807
13082
  startupTokenRefresh = refreshCliToken();
12808
13083
  setInterval(() => void refreshCliToken(), TOKEN_TICK_MS).unref();
@@ -13263,6 +13538,10 @@ async function main() {
13263
13538
  await runPricesCommand(line);
13264
13539
  continue;
13265
13540
  }
13541
+ if (line === "/clear-logs") {
13542
+ await runClearLogs();
13543
+ continue;
13544
+ }
13266
13545
  if (line === "/tokens") {
13267
13546
  // Task #142: WHERE the last request's prompt went. The bar answers "how much is RAG";
13268
13547
  // this answers "and what displaced it", which is the part that tells you whether RAG is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.787",
3
+ "version": "1.0.789",
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",
@@ -77,6 +77,7 @@
77
77
  "poll-errors.mjs",
78
78
  "node-version.mjs",
79
79
  "clear-state.mjs",
80
+ "clear-logs.mjs",
80
81
  "platform-tools.mjs",
81
82
  "job-pools.mjs",
82
83
  "agent-stop.mjs",