@tiens.nguyen/gonext-local-worker 1.0.322 → 1.0.325

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 +93 -24
  2. package/package.json +1 -1
package/gonext-repl.mjs CHANGED
@@ -351,10 +351,64 @@ function slashRestoreHistory() {
351
351
  // menu was near the bottom of the screen and scrolled).
352
352
  let slashOverlayActive = false;
353
353
 
354
- // Redraw the input line (">> " + typed text) AND the command menu below it, fully
355
- // in place. On first activation we scroll N blank rows into existence below the input
356
- // line then return to it, so every later redraw writes into that reserved space
357
- // WITHOUT scrolling — which keeps the save/restore-cursor (ESC 7 / ESC 8) anchor valid.
354
+ // Visible width of PROMPT (">> ") used to put the caret back on the input line by
355
+ // COLUMN, since the string itself carries color escapes that don't occupy cells.
356
+ const PROMPT_COLS = 3;
357
+
358
+ // Truncate to `max` VISIBLE columns, keeping the ANSI color escapes (which occupy no
359
+ // cells) intact. Essential for the overlay: every row it draws must fit on ONE physical
360
+ // terminal row — see slashOverlaySeq for why a wrapped row corrupts the redraw.
361
+ function clipVisible(s, max) {
362
+ if (max <= 0) return "";
363
+ let out = "", seen = 0, sawEscape = false;
364
+ for (let i = 0; i < s.length; i++) {
365
+ if (s[i] === "\x1b") {
366
+ const m = /^\x1b\[[0-9;]*m/.exec(s.slice(i));
367
+ if (m) { out += m[0]; i += m[0].length - 1; sawEscape = true; continue; }
368
+ }
369
+ if (seen >= max) return out + (sawEscape ? "\x1b[0m" : "");
370
+ out += s[i];
371
+ seen++;
372
+ }
373
+ return out;
374
+ }
375
+
376
+ // Build the exact bytes that draw the overlay: the input line (">> " + typed text) with
377
+ // the command menu below it, caret left on the input line.
378
+ //
379
+ // Bug #109 v3 — the ↑/↓ stacking. TWO things have to hold for an in-place redraw, and the
380
+ // old version got the first right and the second wrong:
381
+ //
382
+ // 1. NO ABSOLUTE ANCHOR. The old code re-anchored each redraw with DECSC/DECRC
383
+ // (ESC 7 / ESC 8), an absolute screen position, after pre-scrolling "reserved" rows
384
+ // into existence. There is one save slot per terminal and its meaning changes as soon
385
+ // as the screen scrolls, so it is fragile. Every move here is RELATIVE (ESC[nA) or
386
+ // column-absolute within the current row (ESC[nG), like drawList() (the /server
387
+ // picker), which has always redrawn correctly.
388
+ //
389
+ // 2. NO WRAPPED ROWS — this is what actually bit the user. `ESC[nA` moves up n PHYSICAL
390
+ // rows, but `n` here is the number of LOGICAL menu rows. On a narrow terminal a row
391
+ // like " /model — switch the coding model for this session" occupies TWO physical
392
+ // rows, so the walk back up lands short, the next redraw's "\r ESC[2K" clears the
393
+ // wrong line, and a SECOND ">> /" + menu block is painted below the first — exactly
394
+ // the reported "it prints the item on every ↑". Verified in a terminal model: at 40
395
+ // columns the uncorrected version produces three prompt rows. So every row is clipped
396
+ // to the viewport width, which makes logical rows and physical rows the same thing.
397
+ // (This is also why the /server picker never showed the bug: its rows are short.)
398
+ function slashOverlaySeq(line, cursor, menu, cols) {
399
+ const width = Math.max(20, cols || 80);
400
+ const n = menu.length;
401
+ let out = "\r\x1b[2K" + clipVisible(PROMPT + line, width - 1);
402
+ for (const r of menu) out += "\n\x1b[2K" + clipVisible(r, width - 1);
403
+ // Wipe anything left below (the filtered list can SHRINK as you type), then walk back
404
+ // up to the input line and park the caret at the typed-text offset.
405
+ out += "\x1b[J";
406
+ if (n > 0) out += `\x1b[${n}A`;
407
+ out += `\r\x1b[${Math.min(width, PROMPT_COLS + cursor + 1)}G`;
408
+ return out;
409
+ }
410
+
411
+ // Redraw the input line AND the command menu below it, fully in place.
358
412
  function renderSlashOverlay(sel) {
359
413
  const line = rl.line;
360
414
  const cmds = filteredCommands(line);
@@ -363,42 +417,45 @@ function renderSlashOverlay(sel) {
363
417
  slashSel = sel;
364
418
  slashPrefix = line;
365
419
  const menu = hintRows(cmds, sel);
366
- const n = menu.length;
367
-
368
420
  if (!slashOverlayActive) {
369
421
  slashOverlayActive = true;
370
422
  slashNeutralizeHistory();
371
- process.stdout.write("\n".repeat(n) + `\x1b[${n}A`); // reserve n rows, back to input
372
- slashHintRows = n;
373
- } else if (n !== slashHintRows) {
374
- // The filtered list changed size: clear the old menu, re-reserve.
375
- process.stdout.write("\x1b7\n\x1b[J\x1b8");
376
- process.stdout.write("\n".repeat(n) + `\x1b[${n}A`);
377
- slashHintRows = n;
378
423
  }
379
-
380
- // Input line (carriage-return, clear, prompt + typed), then the menu below, anchored
381
- // by ESC 7 (save at end of input) / ESC 8 (restore) so the caret ends on the input.
382
- let out = "\r\x1b[2K" + PROMPT + line + "\x1b7";
383
- for (const r of menu) out += "\n\x1b[2K" + r;
384
- out += "\x1b8";
385
- process.stdout.write(out);
424
+ slashHintRows = menu.length;
425
+ process.stdout.write(
426
+ slashOverlaySeq(line, rl.cursor ?? line.length, menu, process.stdout.columns)
427
+ );
386
428
  }
387
429
 
388
430
  // Close the overlay: erase the menu below, keep the typed input line, hand drawing back
389
431
  // to readline. Callers that changed the line to a non-slash value should refresh after.
432
+ // Relative moves only, for the same reason as above.
390
433
  function closeSlashOverlay() {
391
434
  if (!slashOverlayActive) {
392
435
  slashRestoreHistory();
393
436
  return;
394
437
  }
395
- process.stdout.write("\x1b7\n\x1b[J\x1b8"); // save, wipe below, restore to input line
438
+ process.stdout.write(
439
+ slashCloseSeq(rl.line, rl.cursor ?? rl.line.length, process.stdout.columns)
440
+ );
396
441
  slashOverlayActive = false;
397
442
  slashHintRows = 0;
398
443
  slashSel = -1;
399
444
  slashNames = [];
400
445
  slashRestoreHistory();
401
446
  }
447
+
448
+ // Bytes that tear the menu down: repaint the input line, erase everything below it, and
449
+ // leave the caret back on the input line at the typed-text offset.
450
+ function slashCloseSeq(line, cursor, cols) {
451
+ const width = Math.max(20, cols || 80);
452
+ return (
453
+ "\r\x1b[2K" + clipVisible(PROMPT + line, width - 1) + // clean, non-wrapping input row
454
+ "\n\x1b[J" + // step below, erase the menu (and anything under it)
455
+ "\x1b[1A" + // back up to the input row (relative)
456
+ `\r\x1b[${Math.min(width, PROMPT_COLS + cursor + 1)}G`
457
+ );
458
+ }
402
459
  if (process.stdin.isTTY) {
403
460
  process.stdin.on("keypress", (_ch, key) => {
404
461
  // An arrow-key list picker (/server) is up → ↑/↓ move the highlight, Enter chooses.
@@ -1779,9 +1836,21 @@ async function runAgentTurn(history) {
1779
1836
  continue;
1780
1837
  }
1781
1838
  if (line.trim() === "") {
1782
- // Several sources (swallowed routing lines, swallowed step summaries) each leave
1783
- // their own blank-line companion behind collapse consecutive blanks into one.
1784
- if (!thinking && !statusShown && !lastWasBlank) {
1839
+ // Vertical rhythm (task #115). Blank lines arrive from the worker unconditionally —
1840
+ // every {"type":"step"} chunk is `text + "\n\n"`, and closeStreamFence() adds its
1841
+ // own — but whether one was PRINTED used to depend on `!thinking && !statusShown`,
1842
+ // i.e. on whether the status ticker happened to have redrawn its two-line block in
1843
+ // the last ~120ms. Identical output rendered two different ways (reported live: the
1844
+ // first ● bullet flush against the prompt, the next three each with a gap above).
1845
+ // Spacing must not be a race, so neither term is consulted any more:
1846
+ // • ACTION stream → swallow every stream-borne blank. Bullets and edit cards form
1847
+ // ONE tight list (task #41); the blank line before the final answer is written
1848
+ // by the CALLER (`console.log(shown ? "" : "\n\n" + answer)`), not here.
1849
+ // • PLAIN-REPLY answer → this text IS the answer, so its blanks are real
1850
+ // paragraph breaks. Keep them (collapsing runs) and clear the status block
1851
+ // first, which is what the old `!statusShown` guard was really protecting.
1852
+ if (plainReplyFlow && !lastWasBlank) {
1853
+ onContent();
1785
1854
  process.stdout.write("\n");
1786
1855
  lastWasBlank = true;
1787
1856
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gonext-local-worker",
3
- "version": "1.0.322",
3
+ "version": "1.0.325",
4
4
  "description": "Polls GoNext cloud API for async local LLM jobs and runs them against Ollama/OpenAI-compatible servers on this Mac",
5
5
  "type": "module",
6
6
  "license": "MIT",