@tiens.nguyen/gonext-cli 1.0.368 → 1.0.369

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.
Files changed (2) hide show
  1. package/gonext-repl.mjs +88 -22
  2. package/package.json +1 -1
package/gonext-repl.mjs CHANGED
@@ -642,25 +642,71 @@ if (process.stdout.isTTY) {
642
642
  });
643
643
  }
644
644
  // ---------- edit-mode bottom bar (display-only + Shift+Tab to cycle) ----------
645
- // A split line + a mode indicator shown just above the prompt each turn: "accept edits on"
646
- // (the agent applies file edits today's behaviour) vs "manually edit". DISPLAY-ONLY for now
647
- // — it tracks the mode so the bar reflects it and Shift+Tab cycles it; wiring it to actually
648
- // gate the agent's edits is a deliberate follow-up.
645
+ // A split rule + a mode indicator pinned BELOW the ">> " input line the bar is the last
646
+ // thing on screen and the user types ABOVE it: "accept edits on" (the agent applies file
647
+ // edits today's behaviour) vs "manually edit". DISPLAY-ONLY for now it tracks the mode
648
+ // so the bar reflects it and Shift+Tab cycles it; wiring it to actually gate the agent's
649
+ // edits is a deliberate follow-up.
649
650
  let editMode = "accept"; // "accept" | "manual"
650
651
  const modeLineText = () =>
651
652
  white("✎ " + (editMode === "accept" ? "accept edits on" : "manually edit")) +
652
653
  dim(" (shift+tab to switch)");
653
- // The bar: a full-width dim rule (turn separator), then the gutter-inset mode line. Printed
654
- // above the prompt in the main loop.
655
- const modeBar = () =>
656
- dim("─".repeat(process.stdout.columns || 80)) + "\n" + GUTTER + modeLineText();
657
- // Shift+Tab redraw: the mode line sits ONE physical row above the ">> " input line. Move up,
658
- // rewrite it, drop back, and let readline restore the input row + caret (cursor-relative, like
659
- // the status ticker / pickers, so it survives scrolling).
660
- const redrawModeLine = () => {
661
- process.stdout.write("\x1b[1A\r\x1b[2K" + GUTTER + modeLineText() + "\x1b[1B\r");
662
- rl._refreshLine();
654
+ const FOOTER_ROWS = 2; // the rule row + the mode row
655
+ // True only while the MAIN loop's ">> " prompt is waiting for input. The bar belongs to that
656
+ // prompt, so it must never appear under an ask()/askSecret() question or during a turn.
657
+ let footerActive = false;
658
+ // Physical rows the prompt + typed line occupies (it wraps once past the terminal width)
659
+ // the bar goes under the LAST of them, not under the caret's row.
660
+ const inputRows = () => {
661
+ const width = Math.max(20, process.stdout.columns || 80);
662
+ return Math.floor((PROMPT_COLS + rl.line.length) / width) + 1;
663
663
  };
664
+ const cursorPos = () =>
665
+ (rl.getCursorPos ? rl.getCursorPos() : null) ?? { rows: 0, cols: PROMPT_COLS };
666
+ // Draw the bar below the input line and put the caret back exactly where readline left it.
667
+ // Relative moves only (same reason as the slash overlay): the block must survive the terminal
668
+ // scrolling — and writing past the last screen row scrolls the input line up TOO, so the
669
+ // matching up-move still lands on it.
670
+ function drawFooter() {
671
+ if (!footerActive || !process.stdout.isTTY || slashOverlayActive) return;
672
+ const width = Math.max(20, process.stdout.columns || 80);
673
+ const pos = cursorPos();
674
+ const down = Math.max(0, inputRows() - 1 - pos.rows); // caret row → last input row
675
+ process.stdout.write(
676
+ (down ? `\x1b[${down}B` : "") +
677
+ "\r\n\x1b[2K" + dim("─".repeat(width)) +
678
+ // CLIPPED: the mode line is ~44 columns, so on a narrow terminal it would WRAP onto a
679
+ // third row — and ESC[nA counts PHYSICAL rows, so the caret would come back one row
680
+ // short and the next keystroke would type over the rule (the #109 failure mode).
681
+ "\r\n\x1b[2K" + clipVisible(GUTTER + modeLineText(), width - 1) +
682
+ "\x1b[J" + // nothing stale below the bar
683
+ `\x1b[${FOOTER_ROWS + down}A\r` + // back to the caret's row
684
+ (pos.cols > 0 ? `\x1b[${pos.cols}C` : "")
685
+ );
686
+ }
687
+ // Wipe the bar (and anything under it) WITHOUT touching the input line — called the instant
688
+ // Enter is pressed so the command's output starts on clean rows instead of printing over the
689
+ // rule and orphaning the mode line.
690
+ function eraseFooter() {
691
+ const was = footerActive;
692
+ footerActive = false;
693
+ if (!was || !process.stdout.isTTY || slashOverlayActive) return;
694
+ const pos = cursorPos();
695
+ const down = Math.max(0, inputRows() - 1 - pos.rows) + 1; // first footer row
696
+ process.stdout.write(
697
+ `\x1b[${down}B\r\x1b[J\x1b[${down}A\r` + (pos.cols > 0 ? `\x1b[${pos.cols}C` : "")
698
+ );
699
+ }
700
+ // Shift+Tab redraw: the bar is below the caret, so a plain redraw in place is enough.
701
+ const redrawModeLine = () => drawFooter();
702
+ // Enter must tear the bar down BEFORE readline echoes its newline (which would land the
703
+ // cursor on the rule row and leave the mode line orphaned below the output). Our normal
704
+ // keypress listener runs AFTER readline's, so this one is prepended to run first.
705
+ if (process.stdin.isTTY) {
706
+ process.stdin.prependListener("keypress", (_ch, key) => {
707
+ if (key && (key.name === "return" || key.name === "enter")) eraseFooter();
708
+ });
709
+ }
664
710
  if (process.stdin.isTTY) {
665
711
  process.stdin.on("keypress", (_ch, key) => {
666
712
  // An arrow-key list picker (/server) is up → ↑/↓ move the highlight, Enter chooses.
@@ -1620,6 +1666,10 @@ rl._refreshLine = () => {
1620
1666
  if (following || listPickerActive || slashOverlayActive) return;
1621
1667
  if (secretInput) return; // don't redraw the typed key (task #127)
1622
1668
  if (_origRefreshLine) _origRefreshLine();
1669
+ // That refresh ends with clearScreenDown — i.e. it just ERASED the bottom bar sitting
1670
+ // under the input line. Redraw it here so every readline redraw (typing, backspace,
1671
+ // history recall, rl.prompt(), a resize) repaints the bar as one atomic step.
1672
+ drawFooter();
1623
1673
  };
1624
1674
 
1625
1675
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -2666,11 +2716,16 @@ async function main() {
2666
2716
  } else {
2667
2717
  // Ctrl+C at a prompt with the slash overlay open = dismiss the overlay first (#109).
2668
2718
  if (slashOverlayActive) closeSlashOverlay();
2719
+ // Wipe the bottom bar before printing below the input line, or the note would land on
2720
+ // the rule row and strand the mode line under it.
2721
+ eraseFooter();
2669
2722
  process.stdout.write("\n" + GUTTER + dim("(/exit to quit)") + "\n");
2670
2723
  // Discard anything half-typed (Ctrl-C at a prompt means "never mind") and
2671
- // redraw through readline so its cursor tracking stays right.
2724
+ // redraw through readline so its cursor tracking stays right — the refresh repaints
2725
+ // the bar under the fresh prompt.
2672
2726
  rl.line = "";
2673
2727
  rl.cursor = 0;
2728
+ footerActive = process.stdout.isTTY;
2674
2729
  rl.prompt();
2675
2730
  }
2676
2731
  };
@@ -2683,19 +2738,30 @@ async function main() {
2683
2738
  let lastCancelledTask = "";
2684
2739
 
2685
2740
  for (;;) {
2686
- // The bottom bar (split line + edit-mode indicator) sits just above the prompt each turn;
2687
- // the mode line is exactly ONE row above ">> " so Shift+Tab can redraw it in place.
2688
- if (process.stdout.isTTY) process.stdout.write(modeBar() + "\n");
2741
+ // The bottom bar (split rule + edit-mode indicator) is pinned BELOW the input line, at
2742
+ // the very bottom of the screen. Arming it here is all it takes: rl.prompt() refreshes
2743
+ // the line, and our _refreshLine hook paints the bar underneath (and repaints it on
2744
+ // every keystroke that erases it). Enter tears it down again.
2745
+ footerActive = process.stdout.isTTY;
2689
2746
  rl.prompt();
2690
- // Absorb blank Enter-presses SILENTLY under the same prompt without this, lines
2691
- // typed while the agent was busy on a long turn (queued, not yet read) all drain at
2692
- // once the moment the turn ends, each re-printing ">> " with no newline between them
2693
- // (e.g. ">> >> >> "). Only re-print the prompt for a genuine stdin close or input.
2747
+ // Absorb blank Enter-presses SILENTLY they never reach the agent. Lines typed while
2748
+ // it was busy on a long turn (queued, not yet read) drain here all at once the moment
2749
+ // the turn ends; a queued line carries no Enter keypress, so its re-prompt overwrites
2750
+ // in place (\r + refresh) rather than stacking ">> >> >> " on one row.
2694
2751
  let lineRaw;
2695
2752
  for (;;) {
2696
2753
  lineRaw = await nextLine();
2697
2754
  if (lineRaw === null || lineRaw.trim()) break;
2755
+ // A bare Enter: its keypress already tore the bar down, and readline left the caret on
2756
+ // a fresh row with no prompt on it. Re-print the prompt so that row isn't blank — the
2757
+ // refresh repaints the bar under it. (Queued lines drain with no keypress at all, so
2758
+ // this re-prompt just overwrites in place and can't stack ">> " on one row.)
2759
+ if (process.stdout.isTTY) {
2760
+ footerActive = true;
2761
+ rl.prompt();
2762
+ }
2698
2763
  }
2764
+ footerActive = false; // a line arrived from the queue (no Enter keypress) → disarm
2699
2765
  if (lineRaw === null) break; // stdin closed
2700
2766
  const line = lineRaw.trim();
2701
2767
  if (line === "/exit" || line === "/quit") break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gonext-cli",
3
- "version": "1.0.368",
3
+ "version": "1.0.369",
4
4
  "description": "GoNext CLI — the gonext 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",