@tiens.nguyen/gonext-cli 1.0.366 → 1.0.368

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 +92 -41
  2. package/package.json +1 -1
package/gonext-repl.mjs CHANGED
@@ -21,7 +21,7 @@
21
21
  * interrupted investigation actually resumes it). /reset clears it.
22
22
  *
23
23
  * Requires the worker daemon to be running (it claims the job).
24
- * Commands: /exit /model /reset /revert [runId] /help Ctrl-C stops following the current run.
24
+ * Commands: /exit /model /reset /manual-edit [runId] /help Ctrl-C stops following the current run.
25
25
  */
26
26
  import { readFile, mkdir, writeFile, unlink } from "node:fs/promises";
27
27
  import { existsSync, statSync, readFileSync } from "node:fs";
@@ -127,6 +127,37 @@ 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
+ // Visible width of a string, ignoring SGR colour escapes (they occupy no cells).
131
+ const vwidth = (s) => String(s).replace(/\x1b\[[0-9;]*m/g, "").length;
132
+ // The gonext startup LOGO (task #131): a small block-art right-chevron — the "go / next"
133
+ // play mark — drawn to the left of the status facts. Kept minimal so it renders on any
134
+ // terminal/theme (block elements only, like the ● ✓ ✗ already in use). Rows are plain text;
135
+ // padded + coloured at render time. LOGO_W = the visible column the status text starts after.
136
+ const LOGO = ["█▖", "███▖", "█████▖", "███▘", "█▘"];
137
+ const LOGO_W = 7;
138
+ // Lay the LOGO and the status rows SIDE BY SIDE (logo left, facts right), or STACK them (logo
139
+ // then facts) when the terminal is too narrow to fit both without wrapping — so the banner is
140
+ // never the soft-wrapped jumble the plain "· … · …" lines used to be (task #131). Side-by-side
141
+ // only when GUTTER + logo + gap + the WIDEST status row fits in `cols`; otherwise stacked, with
142
+ // each status row clipped (clipVisible) so nothing spills past the edge. All rows stay within
143
+ // width → the #109 no-wrap invariant holds. `statusRows` are pre-coloured strings.
144
+ const renderBanner = (statusRows) => {
145
+ const cols = process.stdout.columns || 80;
146
+ const gap = " ";
147
+ const widest = Math.max(0, ...statusRows.map(vwidth));
148
+ const out = [];
149
+ if (GUTTER.length + LOGO_W + gap.length + widest <= cols) {
150
+ const n = Math.max(LOGO.length, statusRows.length);
151
+ for (let i = 0; i < n; i++) {
152
+ const left = i < LOGO.length ? cyan(LOGO[i].padEnd(LOGO_W)) : " ".repeat(LOGO_W);
153
+ out.push(GUTTER + left + gap + (statusRows[i] ?? ""));
154
+ }
155
+ } else {
156
+ for (const r of LOGO) out.push(GUTTER + cyan(r));
157
+ for (const r of statusRows) out.push(GUTTER + clipVisible(r, cols - 1 - GUTTER.length));
158
+ }
159
+ return out.join("\n");
160
+ };
130
161
  // The blank SEAM between the dim action log and the bright final answer (grouping, senior-UI
131
162
  // advice): the completed-action bullets are now a TIGHT group with no blanks between them, so
132
163
  // this one gap is what separates the "what I did" scaffolding from the deliverable. A knob:
@@ -255,7 +286,7 @@ if (argv.includes("--help") || argv.includes("-h")) {
255
286
  " -v, --verbose show the agent's diagnostic logs ([gonext-agent] …) and python stderr\n" +
256
287
  " -h, --help this help\n\n" +
257
288
  "Auth comes from ~/.gonext/worker.env; models/budgets/RAG come from your web Settings.\n" +
258
- "Inside the REPL: /exit · /model · /reset · /revert [runId] · /help — Ctrl-C stops following a turn.\n" +
289
+ "Inside the REPL: /exit · /model · /reset · /manual-edit [runId] · /help — Ctrl-C stops following a turn.\n" +
259
290
  "Questions are enqueued like web questions and executed by the RUNNING\n" +
260
291
  "gonext-cli daemon — its terminal shows the full [gonext-agent] logs.\n" +
261
292
  "Conversation history is saved per-folder in ~/.gonext/sessions and resumed next time\n" +
@@ -329,7 +360,7 @@ const COMMANDS = [
329
360
  { name: "/test-auto", desc: "toggle auto-test (verify & fix until it passes) for this folder" },
330
361
  { name: "/rag-local", desc: "toggle storing this folder's RAG knowledge base locally (vs cloud/S3)" },
331
362
  { name: "/server", desc: "pick a deployment server (host/user) for this session" },
332
- { name: "/revert", usage: "/revert [runId]", desc: "undo the agent's file edits (latest run)" },
363
+ { name: "/manual-edit", aliases: ["/revert"], usage: "/manual-edit [runId]", desc: "undo the agent's file edits so you can edit them yourself (latest run)" },
333
364
  { name: "/reset", aliases: ["/new"], desc: "forget this folder's saved conversation and start fresh" },
334
365
  { name: "/output-token", desc: "show the agent code-model OUTPUT-token total for this workspace" },
335
366
  { name: "/reset-output-token-global", desc: "reset the GLOBAL (you + coding server) output-token total to 0" },
@@ -610,6 +641,26 @@ if (process.stdout.isTTY) {
610
641
  }
611
642
  });
612
643
  }
644
+ // ---------- edit-mode bottom bar (display-only + Shift+Tab to cycle) ----------
645
+ // A split line + a mode indicator shown just above the prompt each turn: "accept edits on"
646
+ // (the agent applies file edits — today's behaviour) vs "manually edit". DISPLAY-ONLY for now
647
+ // — it tracks the mode so the bar reflects it and Shift+Tab cycles it; wiring it to actually
648
+ // gate the agent's edits is a deliberate follow-up.
649
+ let editMode = "accept"; // "accept" | "manual"
650
+ const modeLineText = () =>
651
+ white("✎ " + (editMode === "accept" ? "accept edits on" : "manually edit")) +
652
+ dim(" (shift+tab to switch)");
653
+ // The bar: a full-width dim rule (turn separator), then the gutter-inset mode line. Printed
654
+ // above the prompt in the main loop.
655
+ const modeBar = () =>
656
+ dim("─".repeat(process.stdout.columns || 80)) + "\n" + GUTTER + modeLineText();
657
+ // Shift+Tab redraw: the mode line sits ONE physical row above the ">> " input line. Move up,
658
+ // rewrite it, drop back, and let readline restore the input row + caret (cursor-relative, like
659
+ // the status ticker / pickers, so it survives scrolling).
660
+ const redrawModeLine = () => {
661
+ process.stdout.write("\x1b[1A\r\x1b[2K" + GUTTER + modeLineText() + "\x1b[1B\r");
662
+ rl._refreshLine();
663
+ };
613
664
  if (process.stdin.isTTY) {
614
665
  process.stdin.on("keypress", (_ch, key) => {
615
666
  // An arrow-key list picker (/server) is up → ↑/↓ move the highlight, Enter chooses.
@@ -634,6 +685,15 @@ if (process.stdin.isTTY) {
634
685
  rl.historyIndex = -1;
635
686
  return;
636
687
  }
688
+ // Shift+Tab cycles the edit mode shown in the bottom bar (display-only). Skip while the
689
+ // slash overlay owns the screen so we don't fight its redraw.
690
+ if (key && key.name === "tab" && key.shift) {
691
+ if (!slashOverlayActive) {
692
+ editMode = editMode === "accept" ? "manual" : "accept";
693
+ redrawModeLine();
694
+ }
695
+ return;
696
+ }
637
697
  // Enter is handled by rl.on("line") — don't redraw the hint on the way out.
638
698
  if (key && (key.name === "return" || key.name === "enter")) return;
639
699
  // ↑/↓ while the menu is up = move the highlight IN PLACE. readline drawing is
@@ -770,14 +830,8 @@ async function ensureWorkspace() {
770
830
  );
771
831
  await writeFile(WS_FILE, JSON.stringify({ workspaces: list }, null, 2) + "\n");
772
832
  }
773
- wsSync = { enabled: hit.allowSync !== false, name: hit.name, path: hit.path };
774
- console.log(
775
- wrapGutter(
776
- `workspace: ${hit.name} → ${hit.path} (run ${hit.allowRun ? "enabled" : "disabled"}` +
777
- `${hit.allowSync !== false ? ", cloud sync on" : ""})`,
778
- dim
779
- )
780
- );
833
+ // The workspace facts now live in the startup banner (task #131) — just remember them.
834
+ wsSync = { enabled: hit.allowSync !== false, name: hit.name, path: hit.path, run: !!hit.allowRun };
781
835
  return;
782
836
  }
783
837
  if (!existsSync(cwd) || !statSync(cwd).isDirectory()) return;
@@ -812,7 +866,7 @@ async function ensureWorkspace() {
812
866
  });
813
867
  await mkdir(dirname(WS_FILE), { recursive: true });
814
868
  await writeFile(WS_FILE, JSON.stringify({ workspaces: next }, null, 2) + "\n");
815
- wsSync = { enabled: allowSync, name: basename(cwd), path: cwd };
869
+ wsSync = { enabled: allowSync, name: basename(cwd), path: cwd, run: allowRun };
816
870
  console.log(
817
871
  GUTTER +
818
872
  green(`✓ workspace registered: ${basename(cwd)}`) +
@@ -2442,23 +2496,9 @@ async function runAgentTurn(history) {
2442
2496
 
2443
2497
  // ---------- REPL ----------
2444
2498
  async function main() {
2445
- // Wrap the header so a narrow window doesn't soft-wrap "key wk_…" to column 0; keep the
2446
- // "GoNext agent REPL" label cyan on row 0, dim the rest (and any wrapped continuation rows).
2447
- {
2448
- const LABEL = "GoNext agent REPL";
2449
- const header = `${LABEL} v${REPL_VERSION} · API ${apiBase} · key ${workerKey.slice(0, 8)}…`;
2450
- console.log(
2451
- wrapBlock(header, GUTTER, process.stdout.columns || 80)
2452
- .map((l, i) =>
2453
- !l.length
2454
- ? l
2455
- : i === 0 && l.startsWith(LABEL)
2456
- ? GUTTER + cyan(LABEL) + dim(l.slice(LABEL.length))
2457
- : GUTTER + dim(l)
2458
- )
2459
- .join("\n")
2460
- );
2461
- }
2499
+ // The startup banner (logo + status facts) is printed AFTER the payload fetch below, once the
2500
+ // model/coder/RAG are known see renderBanner (task #131). Nothing prints here so the banner
2501
+ // lands as one clean block.
2462
2502
  // Task #127, Phase 2: the REPL enqueues jobs that the polling DAEMON executes, so ensure
2463
2503
  // one is running — no GoTerminal needed. Idempotent + BC4-safe: startDaemon() no-ops when
2464
2504
  // a daemon (ours, GoTerminal's, or a manual one) is already alive. Best-effort; a failure
@@ -2504,17 +2544,25 @@ async function main() {
2504
2544
  sessionRagMode = local ? "local" : "cloud";
2505
2545
  await saveSessionRagMode(resolve(process.cwd()), sessionRagMode);
2506
2546
  }
2507
- console.log(
2508
- wrapGutter(
2509
- `agent model: ${p.agentModelId || probe?.modelKey || "?"}` +
2510
- (p.codingModelId ? ` · coder: ${p.codingModelId}` : "") +
2511
- (p.ragEnabled ? ` · RAG ${sessionRagMode === "cloud" ? "cloud" : "local"}` : "") +
2512
- ` · auto-test: ${sessionTestAuto ? "on" : "off"}` +
2513
- (selectedServer ? ` · deploy: ${selectedServer.name} (${selectedServer.user}@${selectedServer.host})` : "") +
2514
- (sessionTestAuto ? "" : " (/test-auto to enable)"),
2515
- dim
2516
- )
2517
- );
2547
+ // Startup banner (task #131): the gonext logo on the left, compact status facts on the
2548
+ // right (label dim, value bright) — one fact per short line instead of the old wrapped
2549
+ // … · …" wall. renderBanner lays them side by side, or stacks on a narrow terminal.
2550
+ {
2551
+ const lbl = (s) => dim(s.padEnd(7)); // aligned dim labels, values start at col 7
2552
+ const ws = wsSync?.name || basename(resolve(process.cwd()));
2553
+ const statusRows = [
2554
+ bold(cyan("GONEXT AGENT")) + dim(` v${REPL_VERSION}`),
2555
+ lbl("model") + (p.agentModelId || probe?.modelKey || "?"),
2556
+ lbl("coder") + (p.codingModelId || dim("—")),
2557
+ lbl("RAG") + (p.ragEnabled ? (sessionRagMode === "cloud" ? "cloud" : "local") : dim("off")),
2558
+ sessionTestAuto ? green("auto-test on") : dim("auto-test off"),
2559
+ lbl("folder") + ws + dim(wsSync ? ` (${wsSync.run ? "run" : "read-only"}${wsSync.enabled ? " · sync" : ""})` : ""),
2560
+ ];
2561
+ if (selectedServer)
2562
+ statusRows.push(lbl("deploy") + `${selectedServer.name} ${dim(`(${selectedServer.user}@${selectedServer.host})`)}`);
2563
+ statusRows.push(dim(`key ${workerKey.slice(0, 8)}…`));
2564
+ console.log(renderBanner(statusRows));
2565
+ }
2518
2566
  // Task #127, Phase 3: if the base chat model server isn't answering, offer to set it up
2519
2567
  // (download + start mlx_lm.server). BC3: only runs when NOTHING answers — an already-up
2520
2568
  // model is left untouched, and this is skipped entirely. Interactive TTYs only.
@@ -2635,6 +2683,9 @@ async function main() {
2635
2683
  let lastCancelledTask = "";
2636
2684
 
2637
2685
  for (;;) {
2686
+ // The bottom bar (split line + edit-mode indicator) sits just above the prompt each turn;
2687
+ // the mode line is exactly ONE row above ">> " so Shift+Tab can redraw it in place.
2688
+ if (process.stdout.isTTY) process.stdout.write(modeBar() + "\n");
2638
2689
  rl.prompt();
2639
2690
  // Absorb blank Enter-presses SILENTLY under the same prompt — without this, lines
2640
2691
  // typed while the agent was busy on a long turn (queued, not yet read) all drain at
@@ -2731,7 +2782,7 @@ async function main() {
2731
2782
  }
2732
2783
  continue;
2733
2784
  }
2734
- if (line.startsWith("/revert")) {
2785
+ if (line.startsWith("/manual-edit") || line.startsWith("/revert")) {
2735
2786
  const runId = line.split(/\s+/)[1] ?? "";
2736
2787
  await new Promise((res) => {
2737
2788
  const p = spawn(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gonext-cli",
3
- "version": "1.0.366",
3
+ "version": "1.0.368",
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",