@tiens.nguyen/gonext-local-worker 1.0.261 → 1.0.262

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 +139 -8
  2. package/package.json +1 -1
package/gonext-repl.mjs CHANGED
@@ -383,6 +383,30 @@ rl.on("close", () => {
383
383
  w(null);
384
384
  }
385
385
  });
386
+ // Mouse click-to-expand (task #96): while a turn runs we turn ON xterm button tracking so a
387
+ // click on the live "thinking" status can expand it (see runAgentTurn). Trade-off the user
388
+ // accepted: with tracking on, the terminal's own drag-to-select/copy is captured by us — the
389
+ // user holds Option/Shift to select text meanwhile. ?1000 = report button press+release only
390
+ // (no motion, so we don't flood on mouse-move); ?1006 = SGR extended coords (no 223-col cap).
391
+ const MOUSE_ON = "\x1b[?1000h\x1b[?1006h";
392
+ const MOUSE_OFF = "\x1b[?1000l\x1b[?1006l";
393
+ let _mouseEnabled = false;
394
+ const enableMouse = () => {
395
+ if (_mouseEnabled || !process.stdin.isTTY) return;
396
+ process.stdout.write(MOUSE_ON);
397
+ _mouseEnabled = true;
398
+ };
399
+ const disableMouse = () => {
400
+ if (!_mouseEnabled) return;
401
+ process.stdout.write(MOUSE_OFF);
402
+ _mouseEnabled = false;
403
+ };
404
+ // Safety net: never leave the user's terminal in mouse-reporting mode (would break their
405
+ // shell's selection) if we exit ungracefully.
406
+ process.on("exit", disableMouse);
407
+ // SGR mouse report: ESC [ < btn ; col ; row (M=press, m=release). A left-button PRESS is
408
+ // btn low-2-bits 0 with the wheel bit (64) clear.
409
+ const MOUSE_RE = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g;
386
410
  const nextLine = () =>
387
411
  _lineQueue.length > 0
388
412
  ? Promise.resolve(_lineQueue.shift())
@@ -1047,6 +1071,52 @@ const normalizeThought = (buf) => {
1047
1071
  return "";
1048
1072
  };
1049
1073
 
1074
+ // The FULLER thought (task #96): the same Thought prose normalizeThought summarizes, but
1075
+ // NOT clipped to one clause/100 chars — collapsed to a single spaced string and capped so
1076
+ // the expanded (mouse-click) view has a few lines to show. Returns "" when the live line is
1077
+ // only a tool label (nothing extra to expand). Bounded by fenceThought's own 400-char cap.
1078
+ const thoughtFull = (buf) => {
1079
+ const t = String(buf || "");
1080
+ const ci = t.search(/<code[\s>]/i);
1081
+ const head = (ci >= 0 ? t.slice(0, ci) : t)
1082
+ .replace(/^\s*Thought:\s*/i, "")
1083
+ .replace(/\s+/g, " ")
1084
+ .trim();
1085
+ // Only worth expanding when there's a genuine prose clause (same gate as normalizeThought
1086
+ // path 1) — a bare fragment or pure code has nothing meaningful to unfold.
1087
+ const first = head.split(/\s+/).length;
1088
+ if (head.length < 6 || first < 2 || _thoughtLooksCodey(head)) return "";
1089
+ return head.slice(0, 400);
1090
+ };
1091
+
1092
+ // Word-wrap a plain (ANSI-free) string into at most `maxLines` lines of width ≤ `width`,
1093
+ // appending an ellipsis to the last line when text was dropped (task #96 expand view).
1094
+ const wrapThought = (s, width, maxLines) => {
1095
+ const words = String(s || "").split(/\s+/).filter(Boolean);
1096
+ const lines = [];
1097
+ let cur = "";
1098
+ let truncated = false;
1099
+ for (let i = 0; i < words.length; i++) {
1100
+ const w = words[i];
1101
+ const next = cur ? cur + " " + w : w;
1102
+ if (next.length <= width) {
1103
+ cur = next;
1104
+ } else {
1105
+ if (cur) lines.push(cur);
1106
+ cur = w;
1107
+ if (lines.length >= maxLines) { truncated = true; cur = ""; break; }
1108
+ }
1109
+ }
1110
+ if (cur && lines.length < maxLines) lines.push(cur);
1111
+ else if (cur) truncated = true;
1112
+ if (truncated && lines.length) {
1113
+ const last = lines[maxLines - 1] ?? lines[lines.length - 1];
1114
+ const room = Math.max(1, width - 1);
1115
+ lines[lines.length - 1] = (last.length > room ? last.slice(0, room) : last).replace(/\s+$/, "") + "…";
1116
+ }
1117
+ return lines;
1118
+ };
1119
+
1050
1120
  async function runAgentTurn(history) {
1051
1121
  const res = await fetch(`${apiBase}/api/worker/agent-ask`, {
1052
1122
  method: "POST",
@@ -1090,6 +1160,7 @@ async function runAgentTurn(history) {
1090
1160
  let lastApprovalId = null; // the last command-approval request we've already answered
1091
1161
  let carry = ""; // trailing partial content line held until its newline arrives
1092
1162
  let statusShown = false; // a transient status line is currently on screen
1163
+ let statusLines = 2; // how many rows the status block currently occupies (grows when expanded)
1093
1164
  let warnedPending = false;
1094
1165
  // Transient poll failures (a network blip or a 5xx like the API's momentary "Worker
1095
1166
  // key lookup failed." — a cold Mongo connection, NOT a bad key) must NOT kill a
@@ -1138,14 +1209,21 @@ async function runAgentTurn(history) {
1138
1209
  // random word. `fenceThought` accumulates the raw in-fence text for the current step.
1139
1210
  let liveThought = "";
1140
1211
  let fenceThought = "";
1212
+ // Task #96: the FULLER version of the current thought (for the click-to-expand view) and
1213
+ // whether the user has clicked to expand it. Both reset when the step's Thought changes.
1214
+ let fullLiveThought = "";
1215
+ let thoughtExpanded = false;
1141
1216
  const thinkSecs = () => Math.max(1, Math.round((Date.now() - thinkingSince) / 1000));
1142
1217
 
1143
- // The status is TWO in-place lines — the in-progress action, then the playful word
1144
- // under it. Cursor sits at the end of line 2, so clearing = wipe line 2, move up, wipe
1145
- // line 1, leaving the cursor back at the status's origin for a redraw or real content.
1218
+ // The status is normally TWO in-place lines — the in-progress action, then the playful
1219
+ // word under it but grows by a few grey lines when the thought is expanded (task #96).
1220
+ // Cursor sits at the end of the LAST line, so clearing = wipe it, then move-up-and-wipe
1221
+ // for each line above, leaving the cursor back at the status's origin for a redraw.
1146
1222
  const clearStatus = () => {
1147
1223
  if (!statusShown) return;
1148
- process.stdout.write("\r\x1b[K\x1b[1A\r\x1b[K");
1224
+ let seq = "\r\x1b[K";
1225
+ for (let i = 1; i < statusLines; i++) seq += "\x1b[1A\r\x1b[K";
1226
+ process.stdout.write(seq);
1149
1227
  statusShown = false;
1150
1228
  };
1151
1229
 
@@ -1166,7 +1244,9 @@ async function runAgentTurn(history) {
1166
1244
  // while (e.g. a 150s+ silent prompt-eval on a huge context), the last clause is no longer
1167
1245
  // "what it's doing now" — drop it so the line falls back to the playful word / "Thinking…"
1168
1246
  // instead of freezing on an old thought while the timer climbs ("string… (777s)", #95 A).
1169
- if (liveThought && now - lastContentAt > STALE_THOUGHT_MS) liveThought = "";
1247
+ if (liveThought && now - lastContentAt > STALE_THOUGHT_MS) {
1248
+ liveThought = ""; fullLiveThought = ""; thoughtExpanded = false; // #96: nothing to expand once stale
1249
+ }
1170
1250
  const secs = thinkSecs();
1171
1251
  // Rotate the playful word once per 12s window (guarded so the fast ticker doesn't
1172
1252
  // re-roll it many times within the same second).
@@ -1198,16 +1278,58 @@ async function runAgentTurn(history) {
1198
1278
  const tokLabel =
1199
1279
  dim(` · ${fmtTokens(latestCodeTokens)} tok`) +
1200
1280
  orange(` · ${fmtTokens(sessionMaxCodeTokens)} max`);
1201
- process.stdout.write(
1281
+ let out =
1202
1282
  `${green(BULLET)} ${green(`${fit(primary)}… (${secs}s)`)}` +
1203
- "\n" + ` ${color256(wc, `${glyph} ${thinkWord}…`)}${tokLabel}`
1204
- );
1283
+ "\n" + ` ${color256(wc, `${glyph} ${thinkWord}…`)}${tokLabel}`;
1284
+ let drawn = 2;
1285
+ // Task #96: when the user has clicked the thought line, unfold the fuller thought below
1286
+ // it as up to 4 wrapped GREY lines (dim). Only when there's genuinely more to show than
1287
+ // the truncated primary (a live command / bare "Thinking" has nothing to expand).
1288
+ if (thoughtExpanded && fullLiveThought && !runningCmd) {
1289
+ const wrapped = wrapThought(fullLiveThought, max, 4);
1290
+ for (const wl of wrapped) { out += "\n " + dim(wl); drawn++; }
1291
+ }
1292
+ process.stdout.write(out);
1293
+ statusLines = drawn;
1205
1294
  statusShown = true;
1206
1295
  };
1207
1296
  // 120ms so the spinner animates smoothly (the frame math above uses the wall clock, so
1208
1297
  // seconds still tick once/sec). Only redraws when the model has gone quiet >QUIET_MS.
1209
1298
  const ticker = setInterval(tick, 120);
1210
1299
 
1300
+ // --- Click-to-expand the thinking line (task #96) --------------------------------------
1301
+ // A left-click on the live status block toggles the fuller thought (3-4 grey lines). The
1302
+ // status block sits at the bottom of the viewport (new output keeps scrolling it there),
1303
+ // so we hit-test the click row against the bottom `statusLines` rows. We enable xterm
1304
+ // mouse reporting only for the duration of the turn and always turn it back off in the
1305
+ // finally + on process exit, so the user's shell is never left in mouse-capture mode.
1306
+ const onStatusClick = (row) => {
1307
+ if (!statusShown) return;
1308
+ const rows = process.stdout.rows || 24;
1309
+ const top = rows - statusLines + 1; // 1-based viewport row of the first status line
1310
+ if (row < top || row > rows) return; // click landed outside the status block
1311
+ // Only meaningful when there IS a fuller thought to reveal; otherwise ignore the click
1312
+ // so we don't flicker an empty expand.
1313
+ if (!thoughtExpanded && !(fullLiveThought && !runningCmd)) return;
1314
+ thoughtExpanded = !thoughtExpanded;
1315
+ clearStatus(); // next tick (≤120ms) redraws at the new size; keeps cursor math correct
1316
+ };
1317
+ const onMouseData = (chunk) => {
1318
+ if (!following) return;
1319
+ const s = chunk.toString("latin1");
1320
+ if (s.indexOf("\x1b[<") === -1) return; // fast reject: no SGR mouse report in this chunk
1321
+ MOUSE_RE.lastIndex = 0;
1322
+ let m;
1323
+ while ((m = MOUSE_RE.exec(s))) {
1324
+ const btn = parseInt(m[1], 10);
1325
+ const row = parseInt(m[3], 10);
1326
+ const isPress = m[4] === "M";
1327
+ if (isPress && (btn & 0b11) === 0 && (btn & 64) === 0) onStatusClick(row); // left press
1328
+ }
1329
+ };
1330
+ if (process.stdin.isTTY) process.stdin.on("data", onMouseData);
1331
+ enableMouse();
1332
+
1211
1333
  // Called right before any real (bullet/edit-card/answer) content prints: drop the
1212
1334
  // status lines so content lands cleanly, and end the current phase (so the next phase's
1213
1335
  // seconds count fresh, and any running command is considered finished).
@@ -1219,6 +1341,8 @@ async function runAgentTurn(history) {
1219
1341
  // next step's blinking line starts from "Thinking", not last step's stale clause.
1220
1342
  liveThought = "";
1221
1343
  fenceThought = "";
1344
+ fullLiveThought = ""; // #96: the fuller thought is spent too…
1345
+ thoughtExpanded = false; // …and each new thinking phase starts collapsed.
1222
1346
  lastContentAt = Date.now();
1223
1347
  };
1224
1348
 
@@ -1277,6 +1401,7 @@ async function runAgentTurn(history) {
1277
1401
  fenceThought += line + "\n";
1278
1402
  const th = normalizeThought(fenceThought);
1279
1403
  if (th) liveThought = th;
1404
+ fullLiveThought = thoughtFull(fenceThought); // #96: keep the fuller thought for expand
1280
1405
  }
1281
1406
  continue;
1282
1407
  }
@@ -1416,6 +1541,7 @@ async function runAgentTurn(history) {
1416
1541
  if (inStreamFence && fenceThought.length < 400) {
1417
1542
  const th = normalizeThought(fenceThought + carry);
1418
1543
  if (th) liveThought = th;
1544
+ fullLiveThought = thoughtFull(fenceThought + carry); // #96: fuller thought for expand
1419
1545
  }
1420
1546
  // A non-empty remainder is a partial CONTENT line (heartbeats always arrive whole with
1421
1547
  // a newline), so tokens are actively flowing — keep the ticker asleep.
@@ -1574,6 +1700,11 @@ async function runAgentTurn(history) {
1574
1700
  } finally {
1575
1701
  clearInterval(ticker);
1576
1702
  clearStatus();
1703
+ // Task #96: stop mouse-reporting the moment the turn ends (also on cancel/error — this
1704
+ // finally always runs), and detach the click parser, so the user's shell selection is
1705
+ // never captured between turns.
1706
+ disableMouse();
1707
+ if (process.stdin.isTTY) process.stdin.removeListener("data", onMouseData);
1577
1708
  following = false;
1578
1709
  currentJobId = null;
1579
1710
  // Drop anything typed (but not submitted) during the turn — echo was muted, so it's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gonext-local-worker",
3
- "version": "1.0.261",
3
+ "version": "1.0.262",
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",