@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.
@@ -2434,11 +2434,104 @@ export function firstToolArg(codePart) {
2434
2434
  return { name: m[1], arg: String(value).trim() };
2435
2435
  }
2436
2436
 
2437
+ /**
2438
+ * WHERE A THOUGHT LINE REALLY STARTS (task #333).
2439
+ *
2440
+ * The live line once read `one, call use_skill(name) FIRST, read what it returns, and follow it".
2441
+ * I'll…` for 83 seconds, and later `` `, `bootstrap-api-service.mjs` — add … `` — both the tail of
2442
+ * a sentence whose head had been cut away. The main cause is fixed where it happened (a heartbeat
2443
+ * splitting the thought, see the fence handling in gu-repl.mjs); this is the guard for every other
2444
+ * way a line can arrive headless (a poll boundary, a buffer cap): a line that opens with closing
2445
+ * punctuation, or with a lower-case word, is the middle of something. Start at the next sentence
2446
+ * instead — and when there is none, say nothing ("" keeps whatever the line showed before).
2447
+ */
2448
+ const _SENTENCE_END = /[.!?]["'”’`)\]]*\s+(?=["'“‘`(\[]?[A-Z0-9])/g;
2449
+
2450
+ export function thoughtLineStart(line) {
2451
+ const s = String(line ?? "").trim();
2452
+ if (!s) return "";
2453
+ const headless =
2454
+ /^[`'"”’)\]}]*\s*[,.;:!?)\]}]/.test(s) || // "`, `bootstrap…", ", and then", ") so"
2455
+ /^[a-z]/.test(s); // "one, call use_skill…" — a sentence starts with a capital
2456
+ if (!headless) return s;
2457
+ _SENTENCE_END.lastIndex = 0;
2458
+ const m = _SENTENCE_END.exec(s);
2459
+ return m ? s.slice(m.index + m[0].length).trim() : "";
2460
+ }
2461
+
2462
+ /**
2463
+ * gu's OWN INSTRUCTIONS, recited (task #333). A model weighing its prompt quotes it — "Rules say
2464
+ * call use_skill FIRST when the task matches" — and the status line then shows gu's scaffolding as
2465
+ * if it were news about the user's task. Matched by the fixed phrases of the prompt blocks the
2466
+ * worker writes (gu_skills.prompt_block, the workspace directives); a sentence carrying one is
2467
+ * not shown on the live line.
2468
+ */
2469
+ const _PROMPT_ECHO = /use_skill\(name\)|call use_skill\b[^.]*\bFIRST\b|When the TASK matches|WORK LIKE A SENIOR DEVELOPER|packaged instructions for particular kinds of work/i;
2470
+
2471
+ /** Split prose into sentences, keeping each one's own punctuation. */
2472
+ function _sentences(text) {
2473
+ const s = String(text ?? "").trim();
2474
+ const out = [];
2475
+ let from = 0;
2476
+ _SENTENCE_END.lastIndex = 0;
2477
+ let m;
2478
+ while ((m = _SENTENCE_END.exec(s))) {
2479
+ out.push(s.slice(from, m.index + m[0].length).trim());
2480
+ from = m.index + m[0].length;
2481
+ }
2482
+ if (from < s.length) out.push(s.slice(from).trim());
2483
+ return out.filter(Boolean);
2484
+ }
2485
+
2486
+ /** The line to show live: from its first real sentence, past any recital of gu's own prompt. */
2487
+ export function liveThoughtText(line) {
2488
+ const sentences = _sentences(thoughtLineStart(line));
2489
+ const at = sentences.findIndex((x) => !_PROMPT_ECHO.test(x));
2490
+ return at < 0 ? "" : sentences.slice(at).join(" ");
2491
+ }
2492
+
2493
+ /**
2494
+ * THE QUESTION, SAID BACK (task #333). A thought that opens "The user wants me to use the .env
2495
+ * file and add a template file." sits directly under ">> currently we are not using the .env
2496
+ * file, please use that and also add a template file" — the same thing twice, and on the two
2497
+ * collapsed rows that are all most people read. Measured on this machine's stored turns: 169 of
2498
+ * 296 first thoughts (57%) open this way.
2499
+ *
2500
+ * Leading sentences that restate are dropped; what the model said NEXT — what it will look at,
2501
+ * what it found — becomes the block's first row. Two tests, either is enough: the sentence is
2502
+ * shaped like a restatement ("The user wants/says/asks…", "Let me understand the task"), or most
2503
+ * of its words are the question's own. Only from the START, and never the whole thought: when
2504
+ * every sentence restates, the last one is kept, so a thought is shortened, not lost.
2505
+ * Display only — what the model wrote, and what is sent back to it, are untouched.
2506
+ */
2507
+ const _RESTATE_SHAPE = /^(?:(?:ok(?:ay)?|so|alright|right|now|first)[,.]?\s+)*(?:let me (?:first\s+)?(?:understand|re-?read|restate|parse|break down|think about)\b[^.]*\b(?:task|request|question|ask)|the user(?:'s (?:request|question|task|message))?\s+(?:wants?|says?|said|is asking|asks?|asked|would like|needs?|is|has asked|requests?)|the (?:task|request|question)(?:\s+is)?\s*[:,]|(?:the )?user (?:request|query|message)\s*[:,]|the (?:system prompt|instructions?|rules?|skill)\s+(?:says?|tells?|states?|wants?|requires?)|(?:rules?|instructions?) say\b)/i;
2508
+
2509
+ const _words = (t) => String(t ?? "").toLowerCase().match(/[a-z0-9_.-]{3,}/g) ?? [];
2510
+
2511
+ /** Is this ONE sentence the question (or gu's instructions) said back? */
2512
+ export function isRestatement(sentence, question = "") {
2513
+ if (_RESTATE_SHAPE.test(sentence)) return true;
2514
+ const asked = new Set(_words(question));
2515
+ if (asked.size < 4) return false;
2516
+ const w = _words(sentence);
2517
+ if (w.length < 4) return false;
2518
+ return w.filter((x) => asked.has(x)).length / w.length >= 0.7;
2519
+ }
2520
+
2521
+ export function dropRestatement(prose, question = "") {
2522
+ const sentences = _sentences(prose);
2523
+ if (sentences.length < 2) return String(prose ?? "").trim();
2524
+ const restates = (sentence) => isRestatement(sentence, question);
2525
+ let at = 0;
2526
+ while (at < sentences.length - 1 && restates(sentences[at])) at++;
2527
+ return sentences.slice(at).join(" ");
2528
+ }
2529
+
2437
2530
  /**
2438
2531
  * Raw in-fence stream → a short, meaningful status: a real Thought clause if the model wrote
2439
2532
  * one, else a label naming the tool it is calling, else "" (the caller shows "Thinking…").
2440
2533
  */
2441
- export function normalizeThought(buf) {
2534
+ export function normalizeThought(buf, { question = null } = {}) {
2442
2535
  const t = String(buf || "");
2443
2536
  const ci = t.search(/<code[\s>]/i);
2444
2537
  const head = ci >= 0 ? t.slice(0, ci) : t;
@@ -2455,7 +2548,24 @@ export function normalizeThought(buf) {
2455
2548
  .map((x) => x.trim())
2456
2549
  .find(Boolean);
2457
2550
  const proseIsClause = !!prose && prose.length >= 6 && prose.trim().split(/\s+/).length >= 2;
2458
- if (proseIsClause && !_thoughtLooksCodey(prose)) return prose.slice(0, 100);
2551
+ if (proseIsClause && !_thoughtLooksCodey(prose)) {
2552
+ // …from where a sentence starts, and never gu's own prompt said back (#333). A line with
2553
+ // nothing left falls through to the tool label / "", like any other non-thought.
2554
+ //
2555
+ // WITH THE QUESTION KNOWN (the terminal passes it), the line is taken from the whole head
2556
+ // rather than its first line, minus the question said back: "The user wants me to read
2557
+ // package.json…" sat on the status row for the whole wait while the next sentence — what
2558
+ // the model was actually about to do — was already there. While all there is so far IS the
2559
+ // restatement, nothing is shown: the row keeps "Thinking" for the second that takes.
2560
+ let text = prose;
2561
+ if (question !== null) {
2562
+ const whole = head.replace(/^\s*Thought:\s*/i, "").replace(/\s+/g, " ").trim();
2563
+ text = dropRestatement(whole, question);
2564
+ if (isRestatement(text, question)) text = "";
2565
+ }
2566
+ const shown = liveThoughtText(text);
2567
+ if (shown.length >= 6 && shown.split(/\s+/).length >= 2) return shown.slice(0, 100);
2568
+ }
2459
2569
  // 2) otherwise name the tool being called (from the code part).
2460
2570
  const codePart = ci >= 0 ? t.slice(ci) : t;
2461
2571
  const call = firstToolArg(codePart);
@@ -2586,6 +2696,65 @@ export function runningDetailRows(detail, { width = 80, indent = 5, maxRows = 2
2586
2696
  ];
2587
2697
  }
2588
2698
 
2699
+ /**
2700
+ * THE COMMAND THAT IS RUNNING, on the live status row (task #331): "$ find . -type f … (17s · 5 lines)".
2701
+ *
2702
+ * It used to read "Running <first 70 characters>… (17s)" with every newline squashed to a space,
2703
+ * so a heredoc was one run-on line and a long pipeline stopped mid-flag. Now: the shell's "$", the
2704
+ * command's FIRST LINE (a heredoc's first line is what names it), and at the right what the user
2705
+ * is waiting to learn — how long it has run and how much it has printed.
2706
+ *
2707
+ * ONE ROW, ALWAYS — the status block is redrawn in place and a taller block moves the input line.
2708
+ * The rest of the command is a click (or Ctrl+T) away: see runningCommandRows. → { label, more }
2709
+ * `more` = there is more of the command than this row shows, i.e. the click has something to open.
2710
+ */
2711
+ export function runningCommandLabel(command, { room = 66, secs = 0, lines = 0, expanded = false } = {}) {
2712
+ const all = String(command ?? "").replace(/\r/g, "").split("\n").map((l) => l.replace(/\t/g, " ").replace(/\s+$/, "")).filter((l) => l.trim());
2713
+ const n = Math.max(0, Number(lines) || 0);
2714
+ const suffix = `(${Math.max(0, secs | 0)}s${n ? ` · ${n} line${n === 1 ? "" : "s"}` : ""})`;
2715
+ const multi = all.length > 1;
2716
+ // opened: the rows underneath ARE the command, so the row above them just says what they are
2717
+ if (expanded) return { label: `Running command ${suffix}`, more: true };
2718
+ const head = `$ ${(all[0] ?? "").trim()}`;
2719
+ const space = Math.max(8, (room | 0) - suffix.length - 2);
2720
+ const cut = head.length + (multi ? 2 : 0) > space;
2721
+ const shown = cut ? head.slice(0, Math.max(1, space - 1)).replace(/\s+$/, "") + "…" : head + (multi ? " …" : "");
2722
+ return { label: `${shown} ${suffix}`, more: cut || multi };
2723
+ }
2724
+
2725
+ /**
2726
+ * The whole command, opened under the status row: "└ $ …" with a hanging indent, its own line
2727
+ * breaks kept (a heredoc reads as the script it is), long lines wrapped at a space, and a cap —
2728
+ * past it the last row ends in "…". Rows NEVER exceed `width`: the block is redrawn by walking
2729
+ * physical rows, and a row that wraps on its own breaks every row under it (#109, #138).
2730
+ */
2731
+ export function runningCommandRows(command, { width = 76, maxRows = 5 } = {}) {
2732
+ const lead = "└ $ ";
2733
+ const pad = " ".repeat(lead.length);
2734
+ const room = Math.max(12, (width | 0) - lead.length);
2735
+ const out = [];
2736
+ for (const raw of String(command ?? "").replace(/\r/g, "").split("\n")) {
2737
+ let rest = raw.replace(/\t/g, " ").replace(/\s+$/, "");
2738
+ if (!rest.trim()) continue;
2739
+ while (rest.length > room) {
2740
+ const sp = rest.lastIndexOf(" ", room);
2741
+ // …but never break inside the leading indentation, or a wrapped line loses its shape
2742
+ const cut = sp > room * 0.5 ? sp : room;
2743
+ out.push(rest.slice(0, cut));
2744
+ rest = rest.slice(cut).replace(/^ /, "");
2745
+ }
2746
+ out.push(rest);
2747
+ }
2748
+ if (!out.length) return [];
2749
+ const cap = Math.max(1, maxRows | 0);
2750
+ const rows = out.slice(0, cap);
2751
+ if (out.length > cap) {
2752
+ const last = rows[cap - 1];
2753
+ rows[cap - 1] = (last.length >= room ? last.slice(0, room - 1) : last) + "…";
2754
+ }
2755
+ return rows.map((r, i) => (i ? pad : lead) + r);
2756
+ }
2757
+
2589
2758
  /**
2590
2759
  * How long the turn took, for the line above the answer (task #178).
2591
2760
  *