@tiens.nguyen/gu-cli 1.0.779 → 1.0.780

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
@@ -74,6 +74,7 @@ import {
74
74
  maskKey,
75
75
  otherAgentRows,
76
76
  updateRow,
77
+ noticeRow,
77
78
  updateNotice,
78
79
  answerBlock,
79
80
  updateRestartNote,
@@ -2604,6 +2605,28 @@ function syncBarPinning() {
2604
2605
  if (!canPinBar()) unpinBar();
2605
2606
  else pinBar();
2606
2607
  }
2608
+ /**
2609
+ * THE NOTICE ROW ABOVE THE SEPARATOR (#318) — or null when there is nothing to say.
2610
+ * Priority, highest first: where you are (jump to bottom), what you can act on with a click (an
2611
+ * update), a standing fact about the licence (the gu token notice — yellow only when urgent), and
2612
+ * other agents. Dropped whole, lowest first, when they do not all fit.
2613
+ */
2614
+ function zoneNotice(width) {
2615
+ const items = [];
2616
+ if (scrolledBack()) {
2617
+ const n = scrollOffset;
2618
+ items.push({ key: "jump", clickable: true, paint: accent, text: `↓ jump to bottom (click) · ${n} line${n === 1 ? "" : "s"} below` });
2619
+ }
2620
+ if (availableUpdates.any) {
2621
+ const up = updateRow({ ...availableUpdates, width: 400 });
2622
+ if (up) items.push({ key: "update", clickable: true, paint: accent, text: up.text.slice(up.from) });
2623
+ }
2624
+ if (tokenNotice?.text) items.push({ key: "token", paint: tokenNotice.urgent ? yellow : dim, text: tokenNotice.text });
2625
+ const agents = agentsIndicator(otherAgents);
2626
+ if (agents) items.push({ key: "agents", clickable: true, paint: accent, text: `${agents} running (click)` });
2627
+ return noticeRow(items, { width, gutter: GUTTER });
2628
+ }
2629
+
2607
2630
  /**
2608
2631
  * The whole frozen zone, top to bottom: a separator, a blank row, the INPUT LINE, then the
2609
2632
  * bar's rule, token and mode rows. The blank is what lifts the separator — and the control it
@@ -2624,17 +2647,12 @@ function frozenZone() {
2624
2647
  // #307: a question held until its turn prints anything sits on top of the status block — where
2625
2648
  // it will land once committed, so nothing moves when it does.
2626
2649
  const status = footerActive ? [] : [...pendingEchoRows(), ...liveStatusRows];
2627
- // DECIDED BEFORE THE LAYOUT, because the head's height depends on it: the blank under the
2628
- // separator exists to lift a CONTROL off the typed line, so with no control there is nothing
2629
- // to lift. The same three conditions the rows[sep] writes below test — kept adjacent so they
2630
- // cannot drift into disagreeing about whether a control is showing.
2631
- // The token notice counts only when it FITS — the same test its write below uses. With
2632
- // Boolean(tokenNotice) alone, a narrow terminal lifted a blank row for a line it never drew.
2633
- const tokenFits = Boolean(tokenNotice) && visibleLen(tokenNotice.text) + 4 <= width - 1;
2634
- const headControl =
2635
- scrolledBack() || availableUpdates.any || tokenFits || Boolean(agentsIndicator(otherAgents));
2650
+ // THE SEPARATOR CARRIES NOTHING (#318). What used to ride on it — jump-to-bottom, the update
2651
+ // control, the gu token notice, the agents count — is on its own NOTICE ROW above it; the head
2652
+ // is just the rule, with no blank to lift a control off the typed line.
2653
+ const notice = zoneNotice(width);
2636
2654
  const zone = inputZoneRows({
2637
- headControl,
2655
+ headControl: false,
2638
2656
  width,
2639
2657
  // #258: typing during a turn is not sent — the row says so (an ask() label still comes first).
2640
2658
  prompt: secretInput && secretPrompt
@@ -2679,61 +2697,14 @@ function frozenZone() {
2679
2697
  selection: secretInput ? null : inputSelection,
2680
2698
  highlight: highlightRow,
2681
2699
  });
2682
- const rows = status.concat(zone.rows, askHintRows(width), footerRows());
2683
- // THE SEPARATOR IS NO LONGER ROW 0. With the status block prepended it sits `status.length`
2684
- // rows down, and every affordance below writes to it by index — a bare rows[0] would paint
2685
- // the jump hint over the thought line and hit-test the wrong row.
2686
- const sep = status.length;
2687
- // THE SEPARATOR ROW carries at most one affordance, and the two contend for it:
2688
- //
2689
- // · scrolled back → "↓ jump to bottom", which WINS. It is transient and about where you are
2690
- // right now; an update is neither, and will still be there when you come back.
2691
- // · otherwise, and only when something is genuinely newer → the update control (task #181).
2692
- //
2693
- // Both are stored with their COLUMN span, because the row is hit-tested by column now — one
2694
- // row, two possible targets, and "anywhere on this row" would make the wrong one fire.
2695
- updateRowTarget = null;
2696
- agentsRowTarget = null;
2697
- if (scrolledBack()) {
2698
- const hint = jumpToBottomRow({ below: scrollOffset, width });
2699
- rows[sep] = clipVisible(accent(hint.text), width - 1);
2700
- } else if (availableUpdates.any) {
2701
- const up = updateRow({ ...availableUpdates, width });
2702
- if (up) {
2703
- rows[sep] = clipVisible(dim(up.text.slice(0, up.from)) + accent(up.text.slice(up.from)), width - 1);
2704
- updateRowTarget = { from: up.from, to: up.to };
2705
- }
2706
- } else if (tokenFits) {
2707
- // The gu token's one line (#232), right-aligned like the controls around it. BELOW the update
2708
- // control, because an update is actionable with a click and this is a standing fact; ABOVE the
2709
- // agents count, because it needs the user and that does not. Not clickable. Too narrow to say
2710
- // it whole → the condition above lets the agents count (or nothing) have the row instead.
2711
- const len = visibleLen(tokenNotice.text);
2712
- const from = width - 1 - len;
2713
- // Yellow only when the user must act (plan ended, over the computer limit); an old token is
2714
- // routine and reads in the rule's own dim grey (#292 — the yellow line "seems so serious").
2715
- const paint = tokenNotice.urgent ? yellow : dim;
2716
- rows[sep] = clipVisible(dim("\u2500".repeat(Math.max(0, from - 1)) + " ") + paint(tokenNotice.text), width - 1);
2717
- } else if (agentsIndicator(otherAgents)) {
2718
- // …and when there is no update to offer, the same row carries the agents count (task #195).
2719
- // THREE THINGS CANNOT SHARE ONE ROW, so they are ranked by how time-critical they are:
2720
- // the jump hint is about where you are RIGHT NOW, an update is a standing fact, and other
2721
- // agents change on their own. Update wins over agents because it is rarer and actionable;
2722
- // agents are always one click away through the same row once it is free.
2723
- const label = `${agentsIndicator(otherAgents)} running (click)`;
2724
- const len = visibleLen(label);
2725
- if (len + 4 <= width - 1) {
2726
- const from = width - 1 - len;
2727
- rows[sep] = clipVisible(dim("\u2500".repeat(Math.max(0, from - 1)) + " ") + accent(label), width - 1);
2728
- agentsRowTarget = { from, to: from + len };
2729
- }
2730
- }
2731
- // Spread, not a hand-written pick: this used to list the fields it forwarded, and adding
2732
- // rowCount/caretRow to inputZoneRows left them silently undefined here — the caret would have
2733
- // parked on the first row of a wrapped input forever.
2734
- // statusRows so paintBar can offset the input row and the separator's absolute position;
2735
- // without it every click and caret park would be `status.length` rows out.
2736
- return { ...zone, rows, statusRows: sep };
2700
+ const rows = status.concat(notice ? [notice.row] : [], zone.rows, askHintRows(width), footerRows());
2701
+ // The separator is `status.length (+1 for the notice row)` rows down — everything that writes to
2702
+ // or hit-tests a row by index counts from here.
2703
+ const sep = status.length + (notice ? 1 : 0);
2704
+ updateRowTarget = notice?.spans.update ?? null;
2705
+ agentsRowTarget = notice?.spans.agents ?? null;
2706
+ jumpSpan = notice?.spans.jump ?? null;
2707
+ return { ...zone, rows, statusRows: sep, noticeRow: Boolean(notice) };
2737
2708
  }
2738
2709
  /**
2739
2710
  * Put the output position back at column 0 of its own row — without printing anything.
@@ -2773,8 +2744,10 @@ function paintBar() {
2773
2744
  // The rows exactly as painted, so a double-click reads the same characters the user is
2774
2745
  // looking at rather than re-deriving them from rl.line at a width this may not be.
2775
2746
  inputRowTexts = zone.rows.slice((zone.statusRows ?? 0) + head);
2776
- jumpRowTarget = scrolledBack() ? { row: sepAbs } : null; // the SEPARATOR row
2777
- updateRowAbs = updateRowTarget || agentsRowTarget ? sepAbs : 0; // …the same row, when showing
2747
+ // THE NOTICE ROW is the one directly above the separator (#318) — never the separator itself.
2748
+ const noticeAbs = zone.noticeRow ? sepAbs - 1 : 0;
2749
+ jumpRowTarget = scrolledBack() && jumpSpan ? { row: noticeAbs, ...jumpSpan } : null;
2750
+ updateRowAbs = updateRowTarget || agentsRowTarget ? noticeAbs : 0;
2778
2751
  // preserveCaret:false — the save slot holds the OUTPUT position (see regionUi), and the caret
2779
2752
  // is parked on the input line right after, so there is nothing to preserve here.
2780
2753
  ui(
@@ -2928,6 +2901,8 @@ let liveStatusRows = [];
2928
2901
  // Where the "jump to bottom" line currently is (1-based viewport row + column span), so a click
2929
2902
  // can hit it. Null while the view is live.
2930
2903
  let jumpRowTarget = null;
2904
+ /** The jump-to-bottom hint's column span on the notice row (#318), set by frozenZone. */
2905
+ let jumpSpan = null;
2931
2906
  // The update control's column span on the separator row, and that row's absolute position.
2932
2907
  // Column-scoped (task #181): the row is shared with the jump-to-bottom hint, so a click has to
2933
2908
  // land ON the label rather than anywhere along it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.779",
3
+ "version": "1.0.780",
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",
@@ -2814,3 +2814,41 @@ export function hintRows(lines, { width, gutter = " ", paint = (s) => s } = {}
2814
2814
  }
2815
2815
  return out.map((r) => gutter + paint(r));
2816
2816
  }
2817
+
2818
+ /**
2819
+ * THE NOTICE ROW — its own row, directly ABOVE the separator (#318).
2820
+ *
2821
+ * The rule above ">>" is only a separator (the user, angrily: "the line is for separating the
2822
+ * user's input, not for that"). Four things used to ride on it, one at a time — the jump-to-bottom
2823
+ * hint, the update control, the gu token notice, the agents count — because the row looked free.
2824
+ * Now they share this row instead, right-aligned, joined with " · ", in PRIORITY order (the first
2825
+ * item is the most important). What does not fit is dropped WHOLE, lowest priority first — never
2826
+ * cut mid-word. No items → null, and the zone simply has no such row.
2827
+ *
2828
+ * items: [{ key, text, paint?, clickable? }] → { row, spans: { [key]: { from, to } } } | null
2829
+ * `spans` are 0-based columns of the CLICKABLE items, gutter included — what a click is tested on.
2830
+ */
2831
+ export function noticeRow(items, { width = 80, gutter = " ", sep = " · " } = {}) {
2832
+ const w = Math.max(20, width | 0);
2833
+ const room = w - 1 - visibleLen(gutter);
2834
+ const list = (Array.isArray(items) ? items : []).filter((i) => i && i.text);
2835
+ let kept = list;
2836
+ const joinedLen = (xs) => xs.reduce((n, x, i) => n + visibleLen(x.text) + (i ? sep.length : 0), 0);
2837
+ while (kept.length && joinedLen(kept) > room) kept = kept.slice(0, -1);
2838
+ if (!kept.length) return null;
2839
+ const start = w - 1 - joinedLen(kept);
2840
+ let row = " ".repeat(start);
2841
+ let col = start;
2842
+ const spans = {};
2843
+ kept.forEach((x, i) => {
2844
+ if (i) {
2845
+ row += sep;
2846
+ col += sep.length;
2847
+ }
2848
+ const len = visibleLen(x.text);
2849
+ if (x.clickable) spans[x.key] = { from: col, to: col + len };
2850
+ row += (x.paint ?? ((s) => s))(x.text);
2851
+ col += len;
2852
+ });
2853
+ return { row, spans };
2854
+ }