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

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 +159 -17
  2. package/package.json +1 -1
package/gonext-repl.mjs CHANGED
@@ -48,6 +48,9 @@ const white = (s) => `\x1b[37m${s}\x1b[0m`;
48
48
  // code 34 (blue) which many dark-theme palettes render as purple/indigo instead.
49
49
  const codeColor = (s) => `\x1b[90m${s}\x1b[0m`;
50
50
  const bold = (s) => `\x1b[1m${s}\x1b[0m`;
51
+ // Hover highlight for the clickable thinking line (task #96): bold + underline + bright
52
+ // green so it reads as an interactive "link" the moment the mouse is over it.
53
+ const hoverThought = (s) => `\x1b[1;4;92m${s}\x1b[0m`;
51
54
 
52
55
  // The worker's "still thinking" heartbeat lines look like "Caffeinating… (90s)" — we
53
56
  // SWALLOW them from the terminal (the local ticker below drives progress instead), and
@@ -383,6 +386,33 @@ rl.on("close", () => {
383
386
  w(null);
384
387
  }
385
388
  });
389
+ // Mouse hover+click on the thinking line (task #96): while a turn runs we turn ON xterm
390
+ // ANY-MOTION tracking so we can both HIGHLIGHT the line as the mouse hovers it AND expand it
391
+ // on click (see runAgentTurn). Trade-off the user accepted: with tracking on, the terminal's
392
+ // own drag-to-select/copy is captured by us — the user holds Option/Shift to select text
393
+ // meanwhile. ?1003 = report every button press/release AND all mouse MOTION (needed for hover
394
+ // with no button held); ?1006 = SGR extended coords (no 223-col cap). We disable ?1000/?1002
395
+ // too on teardown in case a terminal had them latched.
396
+ const MOUSE_ON = "\x1b[?1003h\x1b[?1006h";
397
+ const MOUSE_OFF = "\x1b[?1003l\x1b[?1002l\x1b[?1000l\x1b[?1006l";
398
+ let _mouseEnabled = false;
399
+ const enableMouse = () => {
400
+ if (_mouseEnabled || !process.stdin.isTTY) return;
401
+ process.stdout.write(MOUSE_ON);
402
+ _mouseEnabled = true;
403
+ };
404
+ const disableMouse = () => {
405
+ if (!_mouseEnabled) return;
406
+ process.stdout.write(MOUSE_OFF);
407
+ _mouseEnabled = false;
408
+ };
409
+ // Safety net: never leave the user's terminal in mouse-reporting mode (would break their
410
+ // shell's selection) if we exit ungracefully.
411
+ process.on("exit", disableMouse);
412
+ // SGR mouse report: ESC [ < btn ; col ; row (M=press, m=release). btn bit 32 = MOTION
413
+ // (hover/drag), bit 64 = wheel; low 2 bits = which button (0=left). A left CLICK is a press
414
+ // (M) with motion+wheel bits clear and low bits 0; a HOVER is any event with the motion bit.
415
+ const MOUSE_RE = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g;
386
416
  const nextLine = () =>
387
417
  _lineQueue.length > 0
388
418
  ? Promise.resolve(_lineQueue.shift())
@@ -1047,6 +1077,52 @@ const normalizeThought = (buf) => {
1047
1077
  return "";
1048
1078
  };
1049
1079
 
1080
+ // The FULLER thought (task #96): the same Thought prose normalizeThought summarizes, but
1081
+ // NOT clipped to one clause/100 chars — collapsed to a single spaced string and capped so
1082
+ // the expanded (mouse-click) view has a few lines to show. Returns "" when the live line is
1083
+ // only a tool label (nothing extra to expand). Bounded by fenceThought's own 400-char cap.
1084
+ const thoughtFull = (buf) => {
1085
+ const t = String(buf || "");
1086
+ const ci = t.search(/<code[\s>]/i);
1087
+ const head = (ci >= 0 ? t.slice(0, ci) : t)
1088
+ .replace(/^\s*Thought:\s*/i, "")
1089
+ .replace(/\s+/g, " ")
1090
+ .trim();
1091
+ // Only worth expanding when there's a genuine prose clause (same gate as normalizeThought
1092
+ // path 1) — a bare fragment or pure code has nothing meaningful to unfold.
1093
+ const first = head.split(/\s+/).length;
1094
+ if (head.length < 6 || first < 2 || _thoughtLooksCodey(head)) return "";
1095
+ return head.slice(0, 400);
1096
+ };
1097
+
1098
+ // Word-wrap a plain (ANSI-free) string into at most `maxLines` lines of width ≤ `width`,
1099
+ // appending an ellipsis to the last line when text was dropped (task #96 expand view).
1100
+ const wrapThought = (s, width, maxLines) => {
1101
+ const words = String(s || "").split(/\s+/).filter(Boolean);
1102
+ const lines = [];
1103
+ let cur = "";
1104
+ let truncated = false;
1105
+ for (let i = 0; i < words.length; i++) {
1106
+ const w = words[i];
1107
+ const next = cur ? cur + " " + w : w;
1108
+ if (next.length <= width) {
1109
+ cur = next;
1110
+ } else {
1111
+ if (cur) lines.push(cur);
1112
+ cur = w;
1113
+ if (lines.length >= maxLines) { truncated = true; cur = ""; break; }
1114
+ }
1115
+ }
1116
+ if (cur && lines.length < maxLines) lines.push(cur);
1117
+ else if (cur) truncated = true;
1118
+ if (truncated && lines.length) {
1119
+ const last = lines[maxLines - 1] ?? lines[lines.length - 1];
1120
+ const room = Math.max(1, width - 1);
1121
+ lines[lines.length - 1] = (last.length > room ? last.slice(0, room) : last).replace(/\s+$/, "") + "…";
1122
+ }
1123
+ return lines;
1124
+ };
1125
+
1050
1126
  async function runAgentTurn(history) {
1051
1127
  const res = await fetch(`${apiBase}/api/worker/agent-ask`, {
1052
1128
  method: "POST",
@@ -1090,6 +1166,7 @@ async function runAgentTurn(history) {
1090
1166
  let lastApprovalId = null; // the last command-approval request we've already answered
1091
1167
  let carry = ""; // trailing partial content line held until its newline arrives
1092
1168
  let statusShown = false; // a transient status line is currently on screen
1169
+ let statusLines = 2; // how many rows the status block currently occupies (grows when expanded)
1093
1170
  let warnedPending = false;
1094
1171
  // Transient poll failures (a network blip or a 5xx like the API's momentary "Worker
1095
1172
  // key lookup failed." — a cold Mongo connection, NOT a bad key) must NOT kill a
@@ -1138,14 +1215,22 @@ async function runAgentTurn(history) {
1138
1215
  // random word. `fenceThought` accumulates the raw in-fence text for the current step.
1139
1216
  let liveThought = "";
1140
1217
  let fenceThought = "";
1218
+ // Task #96: the FULLER version of the current thought (for the click-to-expand view) and
1219
+ // whether the user has clicked to expand it. Both reset when the step's Thought changes.
1220
+ let fullLiveThought = "";
1221
+ let thoughtExpanded = false;
1222
+ let hoverRow = -1; // last mouse viewport row (from motion events) → highlight when it's on the thought line
1141
1223
  const thinkSecs = () => Math.max(1, Math.round((Date.now() - thinkingSince) / 1000));
1142
1224
 
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.
1225
+ // The status is normally TWO in-place lines — the in-progress action, then the playful
1226
+ // word under it but grows by a few grey lines when the thought is expanded (task #96).
1227
+ // Cursor sits at the end of the LAST line, so clearing = wipe it, then move-up-and-wipe
1228
+ // for each line above, leaving the cursor back at the status's origin for a redraw.
1146
1229
  const clearStatus = () => {
1147
1230
  if (!statusShown) return;
1148
- process.stdout.write("\r\x1b[K\x1b[1A\r\x1b[K");
1231
+ let seq = "\r\x1b[K";
1232
+ for (let i = 1; i < statusLines; i++) seq += "\x1b[1A\r\x1b[K";
1233
+ process.stdout.write(seq);
1149
1234
  statusShown = false;
1150
1235
  };
1151
1236
 
@@ -1166,7 +1251,9 @@ async function runAgentTurn(history) {
1166
1251
  // while (e.g. a 150s+ silent prompt-eval on a huge context), the last clause is no longer
1167
1252
  // "what it's doing now" — drop it so the line falls back to the playful word / "Thinking…"
1168
1253
  // instead of freezing on an old thought while the timer climbs ("string… (777s)", #95 A).
1169
- if (liveThought && now - lastContentAt > STALE_THOUGHT_MS) liveThought = "";
1254
+ if (liveThought && now - lastContentAt > STALE_THOUGHT_MS) {
1255
+ liveThought = ""; fullLiveThought = ""; thoughtExpanded = false; // #96: nothing to expand once stale
1256
+ }
1170
1257
  const secs = thinkSecs();
1171
1258
  // Rotate the playful word once per 12s window (guarded so the fast ticker doesn't
1172
1259
  // re-roll it many times within the same second).
@@ -1187,27 +1274,73 @@ async function runAgentTurn(history) {
1187
1274
  : liveThought || (almostDone ? "Almost done" : "Thinking");
1188
1275
  const max = Math.max(24, (process.stdout.columns || 80) - 14);
1189
1276
  const fit = (s) => (s.length > max ? s.slice(0, max - 1) + "…" : s);
1190
- clearStatus();
1191
- // Line 1: green ● + green phase/seconds (matches the web's green streaming label).
1192
- // Line 2 (indented): the SPINNER + the playful word together, both in the cycling
1193
- // color — the spinner belongs with the word (dot on the label line, spinner leading
1194
- // the flavor line).
1195
- // Task #83/#84: after the playful word, show the running agent-code-model token
1196
- // count (dim) plus the per-workspace PEAK single-request count (orange), e.g.
1197
- // " ⠙ Caffeinating… · 12.3k tok · 8.1k max". Both start at 0 and climb.
1198
1277
  const tokLabel =
1199
1278
  dim(` · ${fmtTokens(latestCodeTokens)} tok`) +
1200
1279
  orange(` · ${fmtTokens(sessionMaxCodeTokens)} max`);
1201
- process.stdout.write(
1202
- `${green(BULLET)} ${green(`${fit(primary)}… (${secs}s)`)}` +
1203
- "\n" + ` ${color256(wc, `${glyph} ${thinkWord}…`)}${tokLabel}`
1204
- );
1280
+ // Task #96: the thought line is "clickable" only when there's a fuller thought to reveal
1281
+ // (a live command / bare "Thinking" has nothing to expand). When clicked, unfold it below
1282
+ // as up to 4 wrapped GREY lines (dim). The whole status block rides the viewport bottom as
1283
+ // output scrolls, so line 1 (the thought) sits at row `rows - drawn + 1` — if the mouse is
1284
+ // hovering THAT row, render the label as an underlined bright-green "link".
1285
+ const clickable = !!(fullLiveThought && !runningCmd);
1286
+ const expLines = thoughtExpanded && clickable ? wrapThought(fullLiveThought, max, 4) : [];
1287
+ const drawn = 2 + expLines.length;
1288
+ const rows = process.stdout.rows || 24;
1289
+ const hovering = clickable && hoverRow === rows - drawn + 1;
1290
+ const label = `${fit(primary)}… (${secs}s)`;
1291
+ clearStatus();
1292
+ // Line 1: green ● + green phase/seconds (matches the web's green streaming label), or the
1293
+ // underlined bright-green hover style when the mouse is over a clickable thought.
1294
+ // Line 2 (indented): the SPINNER + the playful word together, both in the cycling color.
1295
+ // Task #83/#84: after the word, the running agent-code token count (dim) + per-workspace
1296
+ // PEAK single-request count (orange), e.g. " ⠙ Caffeinating… · 12.3k tok · 8.1k max".
1297
+ let out =
1298
+ `${green(BULLET)} ${hovering ? hoverThought(label) : green(label)}` +
1299
+ "\n" + ` ${color256(wc, `${glyph} ${thinkWord}…`)}${tokLabel}`;
1300
+ for (const wl of expLines) out += "\n " + dim(wl);
1301
+ process.stdout.write(out);
1302
+ statusLines = drawn;
1205
1303
  statusShown = true;
1206
1304
  };
1207
1305
  // 120ms so the spinner animates smoothly (the frame math above uses the wall clock, so
1208
1306
  // seconds still tick once/sec). Only redraws when the model has gone quiet >QUIET_MS.
1209
1307
  const ticker = setInterval(tick, 120);
1210
1308
 
1309
+ // --- Hover + click on the thinking line (task #96) -------------------------------------
1310
+ // HOVER: motion events keep `hoverRow`; the tick highlights the thought line (underlined
1311
+ // bright-green "link") whenever the mouse is on its row. CLICK: a left-click on the status
1312
+ // block toggles the fuller thought (3-4 grey lines). The block sits at the bottom of the
1313
+ // viewport (new output keeps scrolling it there), so we hit-test rows against the bottom
1314
+ // `statusLines` rows. Mouse reporting is on only for the turn and always turned back off in
1315
+ // the finally + on process exit, so the user's shell is never left in mouse-capture mode.
1316
+ const onStatusClick = (row) => {
1317
+ if (!statusShown) return;
1318
+ const rows = process.stdout.rows || 24;
1319
+ const top = rows - statusLines + 1; // 1-based viewport row of the first status line
1320
+ if (row < top || row > rows) return; // click landed outside the status block
1321
+ // Only meaningful when there IS a fuller thought to reveal; otherwise ignore the click
1322
+ // so we don't flicker an empty expand.
1323
+ if (!thoughtExpanded && !(fullLiveThought && !runningCmd)) return;
1324
+ thoughtExpanded = !thoughtExpanded;
1325
+ clearStatus(); // next tick (≤120ms) redraws at the new size; keeps cursor math correct
1326
+ };
1327
+ const onMouseData = (chunk) => {
1328
+ if (!following) return;
1329
+ const s = chunk.toString("latin1");
1330
+ if (s.indexOf("\x1b[<") === -1) return; // fast reject: no SGR mouse report in this chunk
1331
+ MOUSE_RE.lastIndex = 0;
1332
+ let m;
1333
+ while ((m = MOUSE_RE.exec(s))) {
1334
+ const btn = parseInt(m[1], 10);
1335
+ const row = parseInt(m[3], 10);
1336
+ const isPress = m[4] === "M";
1337
+ if (btn & 32) { hoverRow = row; continue; } // MOTION (hover/drag) → track for the highlight
1338
+ if (isPress && (btn & 0b11) === 0 && (btn & 64) === 0) onStatusClick(row); // left click → expand
1339
+ }
1340
+ };
1341
+ if (process.stdin.isTTY) process.stdin.on("data", onMouseData);
1342
+ enableMouse();
1343
+
1211
1344
  // Called right before any real (bullet/edit-card/answer) content prints: drop the
1212
1345
  // status lines so content lands cleanly, and end the current phase (so the next phase's
1213
1346
  // seconds count fresh, and any running command is considered finished).
@@ -1219,6 +1352,8 @@ async function runAgentTurn(history) {
1219
1352
  // next step's blinking line starts from "Thinking", not last step's stale clause.
1220
1353
  liveThought = "";
1221
1354
  fenceThought = "";
1355
+ fullLiveThought = ""; // #96: the fuller thought is spent too…
1356
+ thoughtExpanded = false; // …and each new thinking phase starts collapsed.
1222
1357
  lastContentAt = Date.now();
1223
1358
  };
1224
1359
 
@@ -1277,6 +1412,7 @@ async function runAgentTurn(history) {
1277
1412
  fenceThought += line + "\n";
1278
1413
  const th = normalizeThought(fenceThought);
1279
1414
  if (th) liveThought = th;
1415
+ fullLiveThought = thoughtFull(fenceThought); // #96: keep the fuller thought for expand
1280
1416
  }
1281
1417
  continue;
1282
1418
  }
@@ -1416,6 +1552,7 @@ async function runAgentTurn(history) {
1416
1552
  if (inStreamFence && fenceThought.length < 400) {
1417
1553
  const th = normalizeThought(fenceThought + carry);
1418
1554
  if (th) liveThought = th;
1555
+ fullLiveThought = thoughtFull(fenceThought + carry); // #96: fuller thought for expand
1419
1556
  }
1420
1557
  // A non-empty remainder is a partial CONTENT line (heartbeats always arrive whole with
1421
1558
  // a newline), so tokens are actively flowing — keep the ticker asleep.
@@ -1574,6 +1711,11 @@ async function runAgentTurn(history) {
1574
1711
  } finally {
1575
1712
  clearInterval(ticker);
1576
1713
  clearStatus();
1714
+ // Task #96: stop mouse-reporting the moment the turn ends (also on cancel/error — this
1715
+ // finally always runs), and detach the click parser, so the user's shell selection is
1716
+ // never captured between turns.
1717
+ disableMouse();
1718
+ if (process.stdin.isTTY) process.stdin.removeListener("data", onMouseData);
1577
1719
  following = false;
1578
1720
  currentJobId = null;
1579
1721
  // 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.263",
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",