@tiens.nguyen/gonext-cli 1.0.370 → 1.0.372

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 +59 -20
  2. package/package.json +1 -1
package/gonext-repl.mjs CHANGED
@@ -673,6 +673,18 @@ const tokenLineText = () => {
673
673
  );
674
674
  };
675
675
  const FOOTER_ROWS = 3; // the rule row + the token row + the mode row
676
+ // The bar's rows as plain printable strings, one PHYSICAL terminal row each. Both text rows
677
+ // can exceed the width (the mode line is ~44 columns, the token row carries a model name) and
678
+ // would then WRAP onto an extra row — every consumer here counts rows with ESC[nA / ESC[nB, so
679
+ // a wrapped row corrupts the redraw (the #109 failure mode). Hence clipVisible on both.
680
+ const footerRows = () => {
681
+ const width = Math.max(20, process.stdout.columns || 80);
682
+ return [
683
+ dim("─".repeat(width)),
684
+ clipVisible(GUTTER + tokenLineText(), width - 1),
685
+ clipVisible(GUTTER + modeLineText(), width - 1),
686
+ ];
687
+ };
676
688
  // True only while the MAIN loop's ">> " prompt is waiting for input. The bar belongs to that
677
689
  // prompt, so it must never appear under an ask()/askSecret() question or during a turn.
678
690
  let footerActive = false;
@@ -690,18 +702,11 @@ const cursorPos = () =>
690
702
  // matching up-move still lands on it.
691
703
  function drawFooter() {
692
704
  if (!footerActive || !process.stdout.isTTY || slashOverlayActive) return;
693
- const width = Math.max(20, process.stdout.columns || 80);
694
705
  const pos = cursorPos();
695
706
  const down = Math.max(0, inputRows() - 1 - pos.rows); // caret row → last input row
696
707
  process.stdout.write(
697
708
  (down ? `\x1b[${down}B` : "") +
698
- "\r\n\x1b[2K" + dim("".repeat(width)) +
699
- // CLIPPED: both rows can exceed the width (the mode line is ~44 columns, the token row
700
- // carries a model name) and would then WRAP onto an extra row — ESC[nA counts PHYSICAL
701
- // rows, so the caret would come back short and the next keystroke would type over the
702
- // bar (the #109 failure mode).
703
- "\r\n\x1b[2K" + clipVisible(GUTTER + tokenLineText(), width - 1) +
704
- "\r\n\x1b[2K" + clipVisible(GUTTER + modeLineText(), width - 1) +
709
+ footerRows().map((r) => "\r\n\x1b[2K" + r).join("") +
705
710
  "\x1b[J" + // nothing stale below the bar
706
711
  `\x1b[${FOOTER_ROWS + down}A\r` + // back to the caret's row
707
712
  (pos.cols > 0 ? `\x1b[${pos.cols}C` : "")
@@ -722,12 +727,20 @@ function eraseFooter() {
722
727
  }
723
728
  // Shift+Tab redraw: the bar is below the caret, so a plain redraw in place is enough.
724
729
  const redrawModeLine = () => drawFooter();
730
+ // Set by the prepended Enter handler, cleared once the resulting line is consumed. It tells
731
+ // the empty-input path that readline really echoed a newline to the terminal (a live
732
+ // keypress) and there is therefore a row to step back onto — as opposed to a line drained
733
+ // from _lineQueue, which produced no echo to undo.
734
+ let enterEchoed = false;
725
735
  // Enter must tear the bar down BEFORE readline echoes its newline (which would land the
726
736
  // cursor on the rule row and leave the mode line orphaned below the output). Our normal
727
737
  // keypress listener runs AFTER readline's, so this one is prepended to run first.
728
738
  if (process.stdin.isTTY) {
729
739
  process.stdin.prependListener("keypress", (_ch, key) => {
730
- if (key && (key.name === "return" || key.name === "enter")) eraseFooter();
740
+ if (key && (key.name === "return" || key.name === "enter")) {
741
+ enterEchoed = true;
742
+ eraseFooter();
743
+ }
731
744
  });
732
745
  }
733
746
  if (process.stdin.isTTY) {
@@ -1864,7 +1877,9 @@ async function runAgentTurn(history) {
1864
1877
  let lastApprovalId = null; // the last command-approval request we've already answered
1865
1878
  let carry = ""; // trailing partial content line held until its newline arrives
1866
1879
  let statusShown = false; // a transient status line is currently on screen
1867
- let statusLines = 2; // how many rows the status block currently occupies (grows when expanded)
1880
+ let statusLines = 2; // TOTAL rows the status block occupies, bottom bar included (clearStatus)
1881
+ // …of which these are the thought/word rows — the only ones hover + click hit-test against.
1882
+ let statusContentLines = 2;
1868
1883
  let warnedPending = false;
1869
1884
  // Transient poll failures (a network blip or a 5xx like the API's momentary "Worker
1870
1885
  // key lookup failed." — a cold Mongo connection, NOT a bad key) must NOT kill a
@@ -1891,6 +1906,10 @@ async function runAgentTurn(history) {
1891
1906
  // the lifetime per-workspace + global counters once the turn completes.
1892
1907
  let latestOutputTokens = 0;
1893
1908
  let outputTokensPosted = false; // guard: increment the lifetime counters once per turn
1909
+ // A new turn starts from zero on the bar's token row too — otherwise the previous turn's
1910
+ // in/out figures would sit there, looking live, until the first poll reports real numbers.
1911
+ lastTurnInputTokens = 0;
1912
+ lastTurnOutputTokens = 0;
1894
1913
  // First few KB of the raw stream as consumed — a cheap fingerprint to verify the
1895
1914
  // completed resultText really EXTENDS what we streamed (an old worker replaces it
1896
1915
  // with the shorter bare answer, which must not be sliced by shownChars).
@@ -2012,11 +2031,16 @@ async function runAgentTurn(history) {
2012
2031
  // the playful-word line.
2013
2032
  const expLines = thoughtExpanded && rest ? wrapThought(rest, max, 4) : [];
2014
2033
  const drawn = 2 + expLines.length;
2034
+ // The bottom bar rides along UNDER the live status while a turn runs, so it stays on screen
2035
+ // the whole time the agent is thinking instead of only at the prompt. It's part of the same
2036
+ // in-place block: the ticker redraws it every 120ms and clearStatus wipes it with the rest,
2037
+ // so content still lands on clean rows.
2038
+ const bar = process.stdout.isTTY ? footerRows() : [];
2015
2039
  const rows = process.stdout.rows || 24;
2016
2040
  // Hover-highlight only the THOUGHT line (line 1) — its exact viewport row comes from the
2017
2041
  // \x1b[6n probe below (`thoughtRow`); fall back to the viewport-bottom assumption only when
2018
- // the terminal never answered the probe.
2019
- const tRow = thoughtRow >= 1 ? thoughtRow : rows - drawn + 1;
2042
+ // the terminal never answered the probe (the block's top = bottom minus ALL its rows).
2043
+ const tRow = thoughtRow >= 1 ? thoughtRow : rows - (drawn + bar.length) + 1;
2020
2044
  const hovering = clickable && hoverRow === tRow;
2021
2045
  clearStatus();
2022
2046
  // Right after clearing, the cursor sits at the status block's TOP-LEFT = the thought line's
@@ -2034,8 +2058,10 @@ async function runAgentTurn(history) {
2034
2058
  let out = `${markLine(green(BULLET))}${hovering ? hoverThought(label) : green(label)}`;
2035
2059
  for (const wl of expLines) out += "\n" + GUTTER + dim(wl);
2036
2060
  out += "\n" + `${markLine(color256(wc, glyph))}${color256(wc, `${thinkWord}…`)}${tokLabel}`;
2061
+ for (const br of bar) out += "\n" + br;
2037
2062
  process.stdout.write(out);
2038
- statusLines = drawn;
2063
+ statusContentLines = drawn;
2064
+ statusLines = drawn + bar.length;
2039
2065
  statusShown = true;
2040
2066
  };
2041
2067
  // 120ms so the spinner animates smoothly (the frame math above uses the wall clock, so
@@ -2053,9 +2079,10 @@ async function runAgentTurn(history) {
2053
2079
  if (!statusShown) return;
2054
2080
  const rows = process.stdout.rows || 24;
2055
2081
  const top = thoughtRow >= 1 ? thoughtRow : rows - statusLines + 1; // thought line row
2056
- // Toggle only on the thought line + its grey expansion — NOT the last row (the playful
2057
- // word / token line), so a click there doesn't expand what it isn't part of.
2058
- if (row < top || row > top + statusLines - 2) return;
2082
+ // Toggle only on the thought line + its grey expansion — NOT the playful-word/token row,
2083
+ // and NOT the bottom bar below it (statusContentLines excludes the bar), so a click there
2084
+ // doesn't expand what it isn't part of.
2085
+ if (row < top || row > top + statusContentLines - 2) return;
2059
2086
  // Only meaningful when there IS a fuller thought to reveal; otherwise ignore the click
2060
2087
  // so we don't flicker an empty expand.
2061
2088
  if (!thoughtExpanded && !(fullLiveThought && !runningCmd)) return;
@@ -2404,6 +2431,12 @@ async function runAgentTurn(history) {
2404
2431
  if (Number.isFinite(job.agentOutputTokens) && job.agentOutputTokens > latestOutputTokens) {
2405
2432
  latestOutputTokens = job.agentOutputTokens;
2406
2433
  }
2434
+ // Mirror both into the bottom bar's token row so it counts UP live while the turn runs
2435
+ // (the bar rides under the status block now), instead of sitting on the last turn's
2436
+ // figures until this one finishes. The turn-end assignment writes the same numbers again.
2437
+ lastTurnInputTokens = latestCodeTokens;
2438
+ lastTurnOutputTokens = latestOutputTokens;
2439
+ lastTurnTotalOutput = globalOutputTokens + latestOutputTokens;
2407
2440
  const text = typeof job.resultText === "string" ? job.resultText : "";
2408
2441
  if (job.jobStatus === "completed") {
2409
2442
  // Task #104: fold this turn's output-token total into the lifetime per-workspace +
@@ -2774,16 +2807,22 @@ async function main() {
2774
2807
  let lineRaw;
2775
2808
  for (;;) {
2776
2809
  lineRaw = await nextLine();
2810
+ const echoed = enterEchoed;
2811
+ enterEchoed = false;
2777
2812
  if (lineRaw === null || lineRaw.trim()) break;
2778
- // A bare Enter: its keypress already tore the bar down, and readline left the caret on
2779
- // a fresh row with no prompt on it. Re-print the prompt so that row isn't blank — the
2780
- // refresh repaints the bar under it. (Queued lines drain with no keypress at all, so
2781
- // this re-prompt just overwrites in place and can't stack ">> " on one row.)
2813
+ // EMPTY (or whitespace-only) input is not a question it must leave NO trace, or a few
2814
+ // stray Enters stack up a column of bare ">>" rows. readline has already echoed its
2815
+ // newline, so step back UP onto the ">> " row it left behind and re-prompt there:
2816
+ // prompt() erases from the caret down, so the row is rewritten in place and the empty
2817
+ // submit becomes a visual no-op. Nothing to step back over if the line came from
2818
+ // _lineQueue (no keypress, no echo) — hence the `echoed` guard.
2782
2819
  if (process.stdout.isTTY) {
2820
+ if (echoed) process.stdout.write("\x1b[1A");
2783
2821
  footerActive = true;
2784
2822
  rl.prompt();
2785
2823
  }
2786
2824
  }
2825
+ enterEchoed = false;
2787
2826
  footerActive = false; // a line arrived from the queue (no Enter keypress) → disarm
2788
2827
  if (lineRaw === null) break; // stdin closed
2789
2828
  const line = lineRaw.trim();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gonext-cli",
3
- "version": "1.0.370",
3
+ "version": "1.0.372",
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",