@tiens.nguyen/gu-cli 1.0.785 → 1.0.786

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
@@ -127,6 +127,7 @@ import {
127
127
  slashMenuWindow,
128
128
  } from "./terminal-layout.mjs";
129
129
  import { completedFlush, splitLiveStream } from "./completed-flush.mjs";
130
+ import { isScaffoldStep, isGroupableStep, stepGroupRows, relativizePaths } from "./step-group.mjs";
130
131
  import { discoverSkills, matchSkillCommand } from "./skills.mjs";
131
132
  import { appendNew, archivePathFor, archivedWorkspaceKey, markReset, seedArchive } from "./conversation-archive.mjs";
132
133
  import { addAllowRule, listServers as listMcpServers } from "./mcp-config.mjs";
@@ -3280,14 +3281,27 @@ const hoverCtl = createHoverController({
3280
3281
  afterPaint: () => paintBar(),
3281
3282
  });
3282
3283
 
3283
- /** One block's rows, painted: dim normally, full-bright while the pointer is on it. */
3284
- const paintThoughtRows = (block, hovered) => {
3284
+ /**
3285
+ * One block's rows, painted: dim normally, full-bright while the pointer is on it.
3286
+ *
3287
+ * TWO KINDS OF BLOCK share the registry, the hover and the click (#326): a THOUGHT (prose) and a
3288
+ * group of the turn's look-ups (`kind: "steps"`, see step-group.mjs) — "searched code 3× · read
3289
+ * lines 2×", one row until it is clicked open. They differ only in how their rows are made, so
3290
+ * that is the one place they are told apart.
3291
+ */
3292
+ const paintThoughtRows = (block, hovered, collapsed = block.collapsed) => {
3293
+ const paint = (r) => (hovered ? hoverThought(r) : dim(r));
3294
+ if (block.kind === "steps") {
3295
+ return stepGroupRows(block.items, { width: thoughtWidth(), collapsed }).map(
3296
+ (r) => (r.head ? markLine(dim(BULLET)) : GUTTER) + paint(r.text)
3297
+ );
3298
+ }
3285
3299
  const rows = thoughtBlockRows(block.prose, {
3286
3300
  width: thoughtWidth(),
3287
- collapsed: block.collapsed,
3301
+ collapsed,
3288
3302
  lines: THOUGHT_COLLAPSED_LINES,
3289
3303
  });
3290
- return rows.map((r) => GUTTER + (hovered ? hoverThought(r) : dim(r)));
3304
+ return rows.map((r) => GUTTER + paint(r));
3291
3305
  };
3292
3306
 
3293
3307
  /** Width available to a thought row: the screen, less the gutter it hangs in. */
@@ -3328,7 +3342,7 @@ function printThoughtBlock(raw, before) {
3328
3342
  // between, so the two really are continuous — grow it rather than start a new one. Any
3329
3343
  // bullet, blank or answer in between breaks contiguity, and a separate block is then right.
3330
3344
  const prev = thoughtBlocks[thoughtBlocks.length - 1];
3331
- if (prev && prev.collapsed && prev.to === transcript.dropped() + transcript.length() - 2) {
3345
+ if (prev && !prev.kind && prev.collapsed && prev.to === transcript.dropped() + transcript.length() - 2) {
3332
3346
  const merged = prev.prose.replace(/\s+$/, "") + " " + prose;
3333
3347
  const grown = thoughtBlockRows(merged, {
3334
3348
  width: thoughtWidth(),
@@ -3607,12 +3621,7 @@ function hoverThoughtAtRow(row) {
3607
3621
 
3608
3622
  /** Swap one recorded block for its other form and repaint from the transcript. */
3609
3623
  function toggleThoughtBlock(block) {
3610
- const next = thoughtBlockRows(block.prose, {
3611
- width: thoughtWidth(),
3612
- collapsed: !block.collapsed,
3613
- lines: THOUGHT_COLLAPSED_LINES,
3614
- });
3615
- const painted = next.map((r) => GUTTER + dim(r));
3624
+ const painted = paintThoughtRows(block, false, !block.collapsed);
3616
3625
  const newEnd = transcript.replaceRange(block.from, block.to, painted);
3617
3626
  if (newEnd === null) {
3618
3627
  // Scrolled out of the buffer — the block is gone, and so is the thing to toggle.
@@ -11263,6 +11272,67 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
11263
11272
  const openBullet = () => {
11264
11273
  if (!lastWasBlank) process.stdout.write("\n");
11265
11274
  };
11275
+ // THE TURN'S LOOK-UPS AS ONE LINE (#326, step-group.mjs). `stepGroup` is the group still OPEN:
11276
+ // the next look-up joins it and its row is rewritten where it sits. It stays open only while
11277
+ // nothing but thoughts has been printed under it — an edit card, a command's result, a failure,
11278
+ // an approval or the answer ends it, and the next look-up starts a new group below them.
11279
+ let stepGroup = null;
11280
+ const turnCwd = resolve(process.cwd());
11281
+ /** The last finished transcript line — where a block must end to still be the tail. */
11282
+ const transcriptTail = () => transcript.dropped() + transcript.length() - 2;
11283
+ /** Is the open group still followed by nothing but thought blocks? */
11284
+ const stepGroupStillOpen = () => {
11285
+ if (!stepGroup || !barPinned() || scrolledBack()) return false;
11286
+ let end = stepGroup.to;
11287
+ const at = thoughtBlocks.indexOf(stepGroup);
11288
+ if (at < 0) return false; // dropped from the registry (it is capped): no longer ours to rewrite
11289
+ for (let i = at + 1; i < thoughtBlocks.length; i++) {
11290
+ if (thoughtBlocks[i].from !== end + 1) return false;
11291
+ end = thoughtBlocks[i].to;
11292
+ }
11293
+ return end === transcriptTail();
11294
+ };
11295
+ const addLookupStep = (text) => {
11296
+ if (stepGroupStillOpen()) {
11297
+ stepGroup.items.push(text);
11298
+ const painted = paintThoughtRows(stepGroup, hoverCtl.hovered === stepGroup);
11299
+ const newEnd = transcript.replaceRange(stepGroup.from, stepGroup.to, painted);
11300
+ if (newEnd !== null) {
11301
+ const delta = newEnd - stepGroup.to;
11302
+ stepGroup.to = newEnd;
11303
+ if (delta === 0) {
11304
+ // Collapsed: one row before, one row after — rewrite that row and nothing else.
11305
+ ui(thoughtBlockRepaintSeq(stepGroup));
11306
+ paintBar();
11307
+ } else {
11308
+ // Open (the user clicked it mid-turn): it grew, and what is under it moved down.
11309
+ for (const b of thoughtBlocks) {
11310
+ if (b !== stepGroup && b.from > stepGroup.from) {
11311
+ b.from += delta;
11312
+ b.to += delta;
11313
+ }
11314
+ }
11315
+ hoverCtl.reset();
11316
+ paintLiveTail();
11317
+ }
11318
+ return;
11319
+ }
11320
+ stepGroup.items.pop(); // scrolled out of the buffer: it is history now — start a new one
11321
+ }
11322
+ stepGroup = null;
11323
+ const block = { kind: "steps", from: 0, to: 0, items: [text], collapsed: true };
11324
+ const painted = paintThoughtRows(block, false);
11325
+ openBullet();
11326
+ process.stdout.write(painted.join("\n") + "\n");
11327
+ block.to = transcriptTail();
11328
+ block.from = block.to - painted.length + 1;
11329
+ // Unpinned (a window too short for the zone, or piped): nothing can be rewritten in place, so
11330
+ // every look-up is its own line, as it always was — and there is no click to register for.
11331
+ if (!barPinned()) return;
11332
+ thoughtBlocks.push(block);
11333
+ if (thoughtBlocks.length > 200) thoughtBlocks = thoughtBlocks.slice(-200);
11334
+ stepGroup = block;
11335
+ };
11266
11336
  // Chars of the CURRENT trailing (newline-less) `carry` line already flushed live — lets
11267
11337
  // a long plain-reply answer stream character-by-character instead of waiting for a "\n"
11268
11338
  // that might not arrive until the very end (most short replies have none at all).
@@ -11482,6 +11552,12 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
11482
11552
  }
11483
11553
  // Skip the status-clear for a BUFFERED plain reply (STREAM_PLAIN_REPLY=false): nothing
11484
11554
  // prints yet, so the thinking ticker should keep animating until completion.
11555
+ // #326: the turn's own machinery ("Starting 1 MCP server(s)…", "Composing answer…", the
11556
+ // retrieval tally the group line now carries) is live status, not history. --verbose keeps it.
11557
+ if (!plainReplyFlow && !REPL_VERBOSE && isScaffoldStep(line)) {
11558
+ lastContentAt = Date.now();
11559
+ continue;
11560
+ }
11485
11561
  if (alreadyFlushed === 0 && !(plainReplyFlow && !STREAM_PLAIN_REPLY)) onContent();
11486
11562
  if (plainReplyFlow) {
11487
11563
  // Plain-reply content IS the answer. When STREAMING, print it live (default colour) and
@@ -11509,8 +11585,17 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
11509
11585
  // one thing not to miss), and a neutral/informational step keeps the dim ● . The marker
11510
11586
  // hangs in the gutter (markLine); a long bullet wraps with every continuation row
11511
11587
  // re-indented to the SAME gutter, so it shares one text column with the prompt/answer.
11512
- const st = classifyBullet(line.trim());
11513
- const bRows = wrapBlock(line.trim(), GUTTER, process.stdout.columns || 80);
11588
+ // Paths under this folder are shown relative to it (#326): the folder is on the bar.
11589
+ const stepText = relativizePaths(line.trim(), turnCwd);
11590
+ // A LOOK-UP joins the turn's open group — one counted row, a click away from the lines
11591
+ // it stands for — instead of printing a line of its own.
11592
+ if (isGroupableStep(stepText, bulletKind(stepText))) {
11593
+ addLookupStep(stepText);
11594
+ lastWasBlank = false;
11595
+ continue;
11596
+ }
11597
+ const st = classifyBullet(stepText);
11598
+ const bRows = wrapBlock(stepText, GUTTER, process.stdout.columns || 80);
11514
11599
  // One empty line above EVERY bullet now (see openBullet), which SUBSUMES the old
11515
11600
  // special case that opened only the turn's final phase — opensAnswerPhase's blank is
11516
11601
  // just one of these. Still idempotent, so it cannot double the seam the caller owns.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.785",
3
+ "version": "1.0.786",
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",
@@ -110,6 +110,7 @@
110
110
  "install-path.mjs",
111
111
  "skills.mjs",
112
112
  "skill-source.mjs",
113
+ "step-group.mjs",
113
114
  "mcp-config.mjs",
114
115
  "mcp-command.mjs",
115
116
  "hooks-config.mjs",
package/step-group.mjs ADDED
@@ -0,0 +1,140 @@
1
+ /**
2
+ * A TURN'S LOOK-UPS AS ONE LINE (task #326) — pure helpers; gu-repl.mjs does the drawing.
3
+ *
4
+ * Asked for after reading a Claude Code session beside a gu one. gu printed one "●" line per tool
5
+ * call — "Searching code → …", "Reading lines → …", again and again — so a turn that read six
6
+ * files and searched five times was thirty rows of scaffolding above a ten-row answer. Claude Code
7
+ * says the same thing in one line ("Searched for 2 patterns, read 6 files") and keeps the detail a
8
+ * click away. That is what a GROUP is: consecutive look-ups share one row, counted by kind, and a
9
+ * click opens it into the lines gu used to print.
10
+ *
11
+ * WHAT IS GROUPED: a neutral step of the shape "Label → detail" — the agent telling us what it
12
+ * looked at. NOT grouped, because each is something the user must be able to see at a glance:
13
+ * a failure (✗), a command's result (✓), an edit card, an approval, the answer.
14
+ *
15
+ * NOTHING HERE KNOWS A TOOL'S NAME. The kind is whatever stands before the arrow, so a tool added
16
+ * tomorrow is counted with no change here — the same rule the tool list itself follows (#37).
17
+ *
18
+ * SCAFFOLD LINES are the turn's machinery talking about itself ("Starting 1 MCP server(s)…",
19
+ * "Composing answer…", the retrieval tally the group line now carries): live status, not history.
20
+ * Still printed under --verbose.
21
+ */
22
+
23
+ const ARROW = " → ";
24
+
25
+ const SCAFFOLD = [/^Starting \d+ MCP server\(s\)…$/, /^Composing answer…$/, /^Found code by → /];
26
+
27
+ /** The turn's own machinery, not something it did for the user. */
28
+ export function isScaffoldStep(text) {
29
+ const t = String(text ?? "").trim();
30
+ return SCAFFOLD.some((re) => re.test(t));
31
+ }
32
+
33
+ /** "Searching code → seenNames (3 hits)" → "Searching code"; "" when the line has no such shape. */
34
+ export function stepLabel(text) {
35
+ const t = String(text ?? "").trim();
36
+ const at = t.indexOf(ARROW);
37
+ if (at <= 0 || at > 40) return ""; // a sentence that merely contains an arrow is not a step
38
+ const label = t.slice(0, at).trim();
39
+ // A label is a few words ("Indexing project for RAG" is the longest the agent writes) — more
40
+ // than four is prose that happens to hold an arrow.
41
+ if (label.split(/\s+/).length > 4) return "";
42
+ return /^[A-Za-z][A-Za-z0-9 _.\-/]*$/.test(label) ? label : "";
43
+ }
44
+
45
+ /** Does this step join a group? `kind` is bulletKind()'s: only neutral ("info") steps do. */
46
+ export function isGroupableStep(text, kind) {
47
+ return kind === "info" && !isScaffoldStep(text) && stepLabel(text) !== "";
48
+ }
49
+
50
+ // "Searching code" reads as work in progress; a finished group is said in the past. Only the first
51
+ // word changes, and only when it is one of these — anything else is shown as the agent wrote it.
52
+ const PAST = {
53
+ searching: "searched", reading: "read", listing: "listed", locating: "located", exploring: "explored",
54
+ calculating: "calculated", downloading: "downloaded", fetching: "fetched", checking: "checked",
55
+ calling: "called", indexing: "indexed", sending: "sent", deploying: "deployed",
56
+ };
57
+
58
+ function pastLabel(label) {
59
+ const [first, ...rest] = label.split(" ");
60
+ const past = PAST[first.toLowerCase()];
61
+ return past ? [past, ...rest].join(" ") : label;
62
+ }
63
+
64
+ /** "searched code 3× · read lines 2× · listed files" — kinds in the order they first happened. */
65
+ export function stepGroupSummary(items) {
66
+ const counts = new Map();
67
+ for (const it of items ?? []) {
68
+ const label = stepLabel(it) || String(it ?? "").trim();
69
+ counts.set(label, (counts.get(label) ?? 0) + 1);
70
+ }
71
+ return [...counts].map(([label, n]) => pastLabel(label) + (n > 1 ? ` ${n}×` : "")).join(" · ");
72
+ }
73
+
74
+ function wrapWords(text, width) {
75
+ const rows = [];
76
+ let cur = "";
77
+ for (const word of String(text).trim().split(/\s+/)) {
78
+ const next = cur ? cur + " " + word : word;
79
+ if (next.length <= width) cur = next;
80
+ else {
81
+ if (cur) rows.push(cur);
82
+ // a word longer than the row (a path) is cut into row-sized pieces, never dropped
83
+ let w = word;
84
+ while (w.length > width) {
85
+ rows.push(w.slice(0, width));
86
+ w = w.slice(width);
87
+ }
88
+ cur = w;
89
+ }
90
+ }
91
+ if (cur) rows.push(cur);
92
+ return rows;
93
+ }
94
+
95
+ const clip = (text, width) => (text.length <= width ? text : text.slice(0, Math.max(1, width - 1)) + "…");
96
+
97
+ const EXPAND_HINT = " (click to expand)";
98
+ const COLLAPSE_HINT = "(click to collapse)";
99
+
100
+ /**
101
+ * The rows of one group: plain text, no colour, no gutter — the caller owns both.
102
+ * → [{ text, head }]; `head` marks a row that starts an entry (it gets the "●").
103
+ *
104
+ * COLLAPSED IS ALWAYS EXACTLY ONE ROW, whatever the width. The group is updated in place as the
105
+ * turn goes on, and a row that is always one row can be rewritten without moving anything below
106
+ * it — the lesson of every repaint bug in this terminal (#109, #321, #324): rows must not wrap,
107
+ * and what is on screen must stay what gu thinks is on screen.
108
+ * one look-up → the look-up itself (cut with "…" if it is too long; a click shows it whole)
109
+ * several → the counted summary, with the hint when there is room for it
110
+ */
111
+ export function stepGroupRows(items, { width = 76, collapsed = true } = {}) {
112
+ const list = (items ?? []).map((x) => String(x ?? "").trim()).filter(Boolean);
113
+ const w = Math.max(20, width);
114
+ if (!list.length) return [];
115
+ if (collapsed) {
116
+ if (list.length === 1) return [{ text: clip(list[0], w), head: true }];
117
+ const summary = stepGroupSummary(list);
118
+ const withHint = summary + EXPAND_HINT;
119
+ return [{ text: withHint.length <= w ? withHint : clip(summary, w), head: true }];
120
+ }
121
+ const rows = [];
122
+ for (const item of list) wrapWords(item, w).forEach((text, i) => rows.push({ text, head: i === 0 }));
123
+ // Only a group that HAS a shorter form offers to go back to it.
124
+ if (list.length > 1 || rows.length > 1) rows.push({ text: COLLAPSE_HINT, head: false });
125
+ return rows;
126
+ }
127
+
128
+ /**
129
+ * Paths under the project folder, shown relative to it: "/Users/x/proj/src/a.ts:1-9" → "src/a.ts:1-9".
130
+ * Display only. The folder itself becomes "." so "Listing files → /Users/x/proj" still says where.
131
+ */
132
+ export function relativizePaths(text, cwd) {
133
+ const root = String(cwd ?? "").replace(/[\\/]+$/, "");
134
+ const src = String(text ?? "");
135
+ if (!root || root.length < 2 || !src.includes(root)) return src;
136
+ const esc = root.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
137
+ return src
138
+ .replace(new RegExp(esc + "[\\\\/]", "g"), "")
139
+ .replace(new RegExp(esc + "(?![\\w.-])", "g"), ".");
140
+ }
@@ -1038,6 +1038,10 @@ export function thoughtProse(buf) {
1038
1038
  // A leading pipe or quote survives some streams ("| Thought: …").
1039
1039
  .replace(/^\s*[|>\-]+\s*/, "")
1040
1040
  .replace(/^\s*Thought:\s*/i, "")
1041
+ // …and the same label in the MIDDLE, glued to the sentence before it ("…the app does.Thought:
1042
+ // I have all the file contents") — a model that reasons and then restates under the label
1043
+ // (seen live, #326). It is a label, not a word of the thought: a space takes its place.
1044
+ .replace(/(?<=\S)\s*\bThought:\s*/g, " ")
1041
1045
  .replace(/```[a-z]*\n?/gi, " ")
1042
1046
  .replace(/\s+/g, " ")
1043
1047
  .trim();
@@ -1735,7 +1739,12 @@ export function answerBlock({
1735
1739
  doneAt = Date.now(),
1736
1740
  inputLabel = "",
1737
1741
  } = {}) {
1738
- const rendered = render(worked ? `recap: ${answer}` : answer);
1742
+ // NO "recap:" LABEL (#326). It was added when the answer arrived under a long list of step
1743
+ // lines and needed saying what it was; the look-ups are one row now, so the answer is simply
1744
+ // the bright text under them — and a label on it read as if a recap FOLLOWED the answer.
1745
+ // `worked` is still accepted: callers pass it, and it is what a real recap line would gate on.
1746
+ void worked;
1747
+ const rendered = render(answer);
1739
1748
  const clock = formatClockTime(doneAt);
1740
1749
  // The stamp is DROPPED, not defaulted, when the time is unusable — "done Invalid Date" beside
1741
1750
  // a correct duration is worse than no stamp at all. The same for the input figure (#53): the
@@ -2619,6 +2628,11 @@ export function formatThinkTime(ms) {
2619
2628
  const _PICTOGRAPH =
2620
2629
  /[\p{Extended_Pictographic}\p{Emoji_Modifier}\p{Regional_Indicator}️‍]+[ \t]?/gu;
2621
2630
 
2631
+ // A KEYCAP ("1️⃣") is a digit + a variation selector + U+20E3, and only the selector is a
2632
+ // pictograph: stripping left "1⃣ What it is" — a digit wearing a combining box, which terminals
2633
+ // draw badly or over the next letter (seen live, #326). The model meant a numbered point: "1.".
2634
+ const _KEYCAP = /([0-9#*])\uFE0F?\u20E3/g;
2635
+
2622
2636
  /**
2623
2637
  * The answer as PLAIN TEXT: decorative emoji removed (task #178).
2624
2638
  *
@@ -2643,7 +2657,7 @@ export function plainAnswer(text) {
2643
2657
  : // …and within prose, odd indices are inline code spans.
2644
2658
  part
2645
2659
  .split(/(`[^`\n]*`)/g)
2646
- .map((seg, j) => (j % 2 ? seg : seg.replace(_PICTOGRAPH, "")))
2660
+ .map((seg, j) => (j % 2 ? seg : seg.replace(_KEYCAP, "$1.").replace(_PICTOGRAPH, "")))
2647
2661
  .join("")
2648
2662
  )
2649
2663
  .join("")