@tiens.nguyen/gonext-cli 1.0.361 → 1.0.363

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 +80 -42
  2. package/package.json +1 -1
package/gonext-repl.mjs CHANGED
@@ -127,12 +127,18 @@ const wrapGutter = (plain, colorFn = (s) => s) =>
127
127
  wrapBlock(plain, GUTTER, process.stdout.columns || 80)
128
128
  .map((l) => (l.length ? GUTTER + colorFn(l) : l))
129
129
  .join("\n");
130
- // Vertical rhythm between action/answer bullets (task #129). One blank line so the list
131
- // breathes instead of being cramped. A knob: "" = the old tight list, "\n" = one blank.
132
- // Applied as a LEADING blank before a bullet when the previous line wasn't already blank
133
- // so blanks land BETWEEN bullets, never trailing, and it's driven by `lastWasBlank` (what
134
- // was actually printed), NOT the status ticker, so it can't reintroduce the #115 race.
130
+ // The blank SEAM between the dim action log and the bright final answer (grouping, senior-UI
131
+ // advice): the completed-action bullets are now a TIGHT group with no blanks between them, so
132
+ // this one gap is what separates the "what I did" scaffolding from the deliverable. A knob:
133
+ // "" = answer butts against the log, "\n" = one blank line. (Task #129 originally used this
134
+ // BETWEEN every bullet; that read too loose once the bullets went dim see the bullet write.)
135
135
  const LINE_SPACING = "\n";
136
+ // Plain (chat) reply rendering. false = BUFFER the reply and print it WRAPPED at completion
137
+ // (via renderAnswer, from the authoritative resultText) so long lines keep the left margin —
138
+ // the thinking ticker animates during generation, then the wrapped answer appears. true = the
139
+ // old live char-by-char stream, which reads nicely but SOFT-WRAPS a long line back to column 0.
140
+ // This is the one knob to flip back to streaming if the buffered path ever misbehaves live.
141
+ const STREAM_PLAIN_REPLY = false;
136
142
  // ANSI 90 ("bright black") — a reliable neutral grey across terminal themes, unlike
137
143
  // code 34 (blue) which many dark-theme palettes render as purple/indigo instead.
138
144
  const codeColor = (s) => `\x1b[90m${s}\x1b[0m`;
@@ -222,7 +228,23 @@ const fmtTokens = (n) => {
222
228
  if (n < 1_000_000) return `${(n / 1000).toFixed(n < 100_000 ? 1 : 0)}k`;
223
229
  return `${(n / 1_000_000).toFixed(1)}M`;
224
230
  };
225
- const BULLET = "●"; // a completed action
231
+ const BULLET = "●"; // a neutral completed action (informational step)
232
+ const CHECK = "✓"; // a step that succeeded
233
+ const CROSS = "✗"; // a step that FAILED
234
+ const brightText = (s) => s; // default terminal colour (un-dimmed) — the "pops" text weight
235
+ // Classify a finished action bullet for the "spotlight the answer" hierarchy: successes and
236
+ // neutral steps RECEDE (dim), a FAILURE POPS (red ✗ + full-bright text) — it's the one thing
237
+ // the user must not miss while everything else is quiet. Keyed off the worker's step strings
238
+ // ("Command finished/failed → …", "Server up → …", "App launched → …", "HTTP … → HTTP 2xx/4xx/
239
+ // 5xx", "Searching/Downloading/Composing …"). Failure is checked FIRST so a failing HTTP or a
240
+ // "failed" command never reads as success.
241
+ const classifyBullet = (text) => {
242
+ if (/\bfail(ed|s|ure)?\b|\berror(ed)?\b|→\s*(?:HTTP\s*)?[45]\d\d\b/i.test(text))
243
+ return { mark: CROSS, markColor: red, textColor: brightText };
244
+ if (/^(?:Command finished|Server up|App launched)\b|→\s*(?:HTTP\s*)?2\d\d\b|\b(?:passed|succeeded|ready)\b/i.test(text))
245
+ return { mark: CHECK, markColor: green, textColor: dim };
246
+ return { mark: BULLET, markColor: dim, textColor: dim };
247
+ };
226
248
 
227
249
  // ---------- flags ----------
228
250
  const argv = process.argv.slice(2);
@@ -356,7 +378,11 @@ const rl = createInterface({
356
378
  terminal: IS_TTY,
357
379
  completer: slashCompleter,
358
380
  });
359
- const PROMPT = GUTTER + cyan(">> ");
381
+ // ">>" is a MARGIN MARKER like the ● bullets — it hangs in the gutter (no leading gutter) so
382
+ // the user's typed input lands on the SAME text column (GUTTER+1) as the agent's bullets and
383
+ // answer. ">> " is exactly GUTTER-wide, so input aligns; the ">>" right-edge sits at the same
384
+ // column the ● / ✓ / ✗ markers hang at (markers right-aligned in the margin, text in one column).
385
+ const PROMPT = cyan(">> ");
360
386
  rl.setPrompt(PROMPT);
361
387
  // Side effect of terminal:false above: readline no longer intercepts arrow keys for
362
388
  // command-history recall, so pressing ↑/↓/→/← (or Home/End/Delete) sends the RAW escape
@@ -461,9 +487,9 @@ let secretInput = false;
461
487
 
462
488
  // Visible width of PROMPT (">> ") — used to put the caret back on the input line by
463
489
  // COLUMN, since the string itself carries color escapes that don't occupy cells.
464
- // Visible width of PROMPT = the gutter + ">> " (task #128). Used by the slash-overlay
465
- // cursor math to park the caret at the right column, so it must include the gutter.
466
- const PROMPT_COLS = GUTTER.length + 3;
490
+ // Visible width of PROMPT = ">> " (3), since it now hangs with no leading gutter. Used by the
491
+ // slash-overlay cursor math to park the caret at the input's text column (GUTTER+1 = col 4).
492
+ const PROMPT_COLS = 3;
467
493
 
468
494
  // Truncate to `max` VISIBLE columns, keeping the ANSI color escapes (which occupy no
469
495
  // cells) intact. Essential for the overlay: every row it draws must fit on ONE physical
@@ -2105,7 +2131,9 @@ async function runAgentTurn(history) {
2105
2131
  // • PLAIN-REPLY answer → this text IS the answer, so its blanks are real
2106
2132
  // paragraph breaks. Keep them (collapsing runs) and clear the status block
2107
2133
  // first, which is what the old `!statusShown` guard was really protecting.
2108
- if (plainReplyFlow && !lastWasBlank) {
2134
+ // When buffering the plain reply, blanks live in resultText and are re-emitted by the
2135
+ // caller's wrapped reprint — swallow them here (and keep the ticker up: no onContent).
2136
+ if (plainReplyFlow && STREAM_PLAIN_REPLY && !lastWasBlank) {
2109
2137
  onContent();
2110
2138
  process.stdout.write("\n");
2111
2139
  lastWasBlank = true;
@@ -2120,38 +2148,42 @@ async function runAgentTurn(history) {
2120
2148
  const mRun = /^Running → (.+)$/.exec(line.trim());
2121
2149
  if (mRun) { runningCmd = mRun[1]; thinking = false; lastContentAt = Date.now(); continue; }
2122
2150
  }
2123
- if (alreadyFlushed === 0) onContent();
2151
+ // Skip the status-clear for a BUFFERED plain reply (STREAM_PLAIN_REPLY=false): nothing
2152
+ // prints yet, so the thinking ticker should keep animating until completion.
2153
+ if (alreadyFlushed === 0 && !(plainReplyFlow && !STREAM_PLAIN_REPLY)) onContent();
2124
2154
  if (plainReplyFlow) {
2125
- // Plain-reply content IS the answer print it in the default color and remember
2126
- // it's visible so the caller doesn't reprint it once the turn completes.
2127
- // Gutter (task #128) ONLY at line-start: when alreadyFlushed>0 this is a re-flush of
2128
- // a growing SAME physical line, so a gutter here would land mid-line.
2129
- process.stdout.write(
2130
- (alreadyFlushed === 0 ? GUTTER : "") + line.slice(alreadyFlushed) + "\n"
2131
- );
2155
+ // Plain-reply content IS the answer. When STREAMING, print it live (default colour) and
2156
+ // mark it shown so the caller doesn't reprint. When BUFFERING, only accumulate — the
2157
+ // caller reprints the whole thing WRAPPED from resultText (shown stays false), so a long
2158
+ // line keeps its margin instead of soft-wrapping. Gutter only at a line start.
2132
2159
  liveAnswer += line.slice(alreadyFlushed) + "\n";
2133
- answerShownLive = true;
2160
+ if (STREAM_PLAIN_REPLY) {
2161
+ process.stdout.write(
2162
+ (alreadyFlushed === 0 ? GUTTER : "") + line.slice(alreadyFlushed) + "\n"
2163
+ );
2164
+ answerShownLive = true;
2165
+ }
2134
2166
  } else {
2135
- // The remaining out-of-fence ACTION events ("Command passed → …", "Searching code
2136
- // → …", "Server up → …", "Composing answer…") each print as ONE solid bullet —
2137
- // the clean, summarized action list (task #41), now with a blank line between them
2138
- // for breathing room (task #129). The spacer is a LEADING blank emitted only when
2139
- // the previous line wasn't already blank one gap BETWEEN bullets, none before the
2140
- // first (which follows the prompt) and none trailing. Deterministic (keyed off
2141
- // lastWasBlank, not the ticker) so #115's status-race stays fixed.
2142
- if (LINE_SPACING && !lastWasBlank) process.stdout.write(LINE_SPACING);
2143
- // Completed actions are SCAFFOLDING, not the deliverable commit them DIM the instant
2144
- // they print (they're history the moment they land), so the eye lands on the full-bright
2145
- // final answer below (the "spotlight the answer" design). The loud "happening now"
2146
- // element stays the green live ticker, not this settled history. Dimming at print time
2147
- // (not re-colouring afterwards) avoids cursor-walking back over wrapped/scrolled rows.
2148
- // The is a hanging margin marker (markLine): text lands on the gutter column, and a
2149
- // long bullet wraps so every continuation row re-indents to that SAME gutter — the whole
2150
- // bullet shares one text column with the prompt/banner instead of soft-wrapping to col 0.
2167
+ // The out-of-fence ACTION events ("Command finished/failed → …", "Server up → …",
2168
+ // "Searching code → …", "Composing answer…") print as ONE solid bullet each a clean,
2169
+ // summarized action list (task #41). They form a TIGHT group now (no blank between them,
2170
+ // superseding #129's uniform spacing): completed actions are dim scaffolding, so a
2171
+ // compact grey log reads better than a sparse one, and the ONE blank that matters is
2172
+ // before the bright answer (the caller's LINE_SPACING seam), separating the log from the
2173
+ // deliverable. #115's status-race stays gone — there are simply no per-bullet blanks.
2174
+ //
2175
+ // Glyph vocabulary (senior-UI advice): a step that SUCCEEDED gets a green (with dim
2176
+ // text a receded success), a FAILURE gets a red + full-bright text so it POPS (the
2177
+ // one thing not to miss), and a neutral/informational step keeps the dim . The marker
2178
+ // hangs in the gutter (markLine); a long bullet wraps with every continuation row
2179
+ // re-indented to the SAME gutter, so it shares one text column with the prompt/answer.
2180
+ const st = classifyBullet(line.trim());
2151
2181
  const bRows = wrapBlock(line.trim(), GUTTER, process.stdout.columns || 80);
2152
2182
  process.stdout.write(
2153
2183
  bRows
2154
- .map((r, i) => (i === 0 ? markLine(dim(BULLET)) + dim(r) : GUTTER + dim(r)))
2184
+ .map((r, i) =>
2185
+ i === 0 ? markLine(st.markColor(st.mark)) + st.textColor(r) : GUTTER + st.textColor(r)
2186
+ )
2155
2187
  .join("\n") + "\n"
2156
2188
  );
2157
2189
  }
@@ -2161,7 +2193,8 @@ async function runAgentTurn(history) {
2161
2193
  // reply) and can take many seconds to generate — without this, nothing would print
2162
2194
  // until the whole thing finally lands, right back to the "wait then dump" problem
2163
2195
  // this was meant to fix. Only safe in the plain-reply flow (see plainReplyFlow above).
2164
- if (plainReplyFlow && !inCode && carry.length > carryFlushedLen) {
2196
+ // Buffered mode skips this entirely the reply is reprinted wrapped at completion.
2197
+ if (STREAM_PLAIN_REPLY && plainReplyFlow && !inCode && carry.length > carryFlushedLen) {
2165
2198
  if (carryFlushedLen === 0) onContent();
2166
2199
  // Gutter (task #128) only at the pending line's START; a continuation appends bare.
2167
2200
  process.stdout.write(
@@ -2182,8 +2215,10 @@ async function runAgentTurn(history) {
2182
2215
  fullLiveThought = thoughtFull(fenceThought + carry); // #96: fuller thought for expand
2183
2216
  }
2184
2217
  // A non-empty remainder is a partial CONTENT line (heartbeats always arrive whole with
2185
- // a newline), so tokens are actively flowing — keep the ticker asleep.
2186
- if (carry.trim() !== "") lastContentAt = Date.now();
2218
+ // a newline), so tokens are actively flowing — keep the ticker asleep. EXCEPT a buffered
2219
+ // plain reply: nothing prints during generation, so let the ticker keep animating
2220
+ // "Thinking…" (don't mark content as flowing) until the wrapped answer lands at completion.
2221
+ if (carry.trim() !== "" && !(plainReplyFlow && !STREAM_PLAIN_REPLY)) lastContentAt = Date.now();
2187
2222
  };
2188
2223
 
2189
2224
  try {
@@ -2272,11 +2307,14 @@ async function runAgentTurn(history) {
2272
2307
  // nothing (the reported "Hello! How" cut) or a garbage tail. Verify with the
2273
2308
  // seenRaw fingerprint; on a mismatch, recover by answer-prefix instead: print
2274
2309
  // whatever of the final answer wasn't already shown live.
2310
+ // Buffered plain reply: nothing streamed live (answerShownLive stays false), so skip
2311
+ // the suffix reconciliation entirely — the caller reprints the whole reply WRAPPED from
2312
+ // resultText (shown=false), which is also more truncation-proof than the live-stream math.
2275
2313
  const extendsStream = text.length >= shownChars && text.startsWith(seenRaw);
2276
- if (plainReplyFlow && extendsStream && text.length > shownChars) {
2314
+ if (STREAM_PLAIN_REPLY && plainReplyFlow && extendsStream && text.length > shownChars) {
2277
2315
  consume(text.slice(shownChars));
2278
2316
  shownChars = text.length;
2279
- } else if (plainReplyFlow && !extendsStream && answerShownLive) {
2317
+ } else if (STREAM_PLAIN_REPLY && plainReplyFlow && !extendsStream && answerShownLive) {
2280
2318
  const ans = answerFrom(text);
2281
2319
  let rest = ans.startsWith(liveAnswer) ? ans.slice(liveAnswer.length) : null;
2282
2320
  if (rest === null) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gonext-cli",
3
- "version": "1.0.361",
3
+ "version": "1.0.363",
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",