@oxecli/oxe 1.0.97 → 1.0.98

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 (3) hide show
  1. package/dist/cli.js +63 -12
  2. package/dist/ui.js +62 -10
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -4,7 +4,7 @@ import { fileURLToPath } from "node:url";
4
4
  import { loadOrPrompt, default_reasoning_effort, max_action_chars, max_resume_history_items, runtimeOsSummary, max_context_tokens, context_overhead_margin, } from "./config.js";
5
5
  import { InferenceEngine, estimateTokens } from "./engine.js";
6
6
  import { saveSession, loadSession, listSessions, nextSessionId, conversationLabel, COMMAND_HELP, stripOrphanCalls, toolOutputFailed, } from "./sessions.js";
7
- import { clearScreen, enableAnsi, askBottomPrompt, userDisplayText, aiMarkdown, mutedMarkdown, messageText, toolCallLabel, formatToolResult, truncateEllipsis, stripAnsi, plainLen, truncateStyled, terminalWidth, TOOL_RESULT_MAX_LINES, MAX_COMMAND_DISPLAY_CHARS, renderPanel, renderTableString, enableRawStdin, disableRawStdin, waitRawKey, dockAppendContent, dockSetInactive, dockTearDown, startDraftCapture, } from "./ui.js";
7
+ import { clearScreen, enableAnsi, askBottomPrompt, userDisplayText, aiMarkdown, mutedMarkdown, messageText, toolCallLabel, formatToolResult, truncateEllipsis, stripAnsi, plainLen, truncateStyled, terminalWidth, TOOL_RESULT_MAX_LINES, MAX_COMMAND_DISPLAY_CHARS, renderPanel, renderTableString, enableRawStdin, disableRawStdin, waitRawKey, dockAppendContent, dockSetInactive, dockTearDown, dockRenderPrompt, startDraftCapture, } from "./ui.js";
8
8
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
9
  const require = createRequire(import.meta.url);
10
10
  const packageInfo = require("../package.json");
@@ -20,6 +20,13 @@ export class CLI {
20
20
  promptHistory = [];
21
21
  lastPrompt = "";
22
22
  interruptPending = false;
23
+ /**
24
+ * When a prompt is submitted during an in-flight query (Enter while the AI is
25
+ * responding), we interrupt the query silently and queue the submitted prompt
26
+ * here so the next loop iteration processes it directly without showing the
27
+ * "Interrupted agent" alert.
28
+ */
29
+ autoSubmit = null;
23
30
  constructor() {
24
31
  clearScreen();
25
32
  this.renderHeader();
@@ -404,14 +411,33 @@ export class CLI {
404
411
  const pct = Math.max(0, Math.min(100, Math.round(((budget - used) / budget) * 100)));
405
412
  // Queries run the proven settle-on-Enter flow: the prompt settles and
406
413
  // the query streams above it (robust dock streaming). While the query
407
- // runs, typed keys are captured into a draft; the draft (or the sent
408
- // prompt) is restored into the next prompt box so input is never lost.
409
- const [input, spans] = await askBottomPrompt("You", "❯", this.promptHistory, {
414
+ // runs, the prompt box stays live-editable: typed keys are rendered
415
+ // into the box, and Enter submits a new prompt that interrupts the
416
+ // running response (no "Interrupted agent" alert) and is processed
417
+ // immediately. A plain (non-submitted) draft is restored into the next
418
+ // prompt box so input is never lost.
419
+ const hints = {
410
420
  left: "\x1b[90mPress \x1b[1;36mCtrl+C\x1b[0m\x1b[90m to interrupt\x1b[0m",
411
421
  right: `\x1b[1;36m${pct}%\x1b[0m \x1b[90muntil auto-compact\x1b[0m`,
412
- }, this.lastPrompt);
413
- if (!input.trim())
414
- continue;
422
+ };
423
+ let input;
424
+ let spans;
425
+ if (this.autoSubmit) {
426
+ // A prompt was submitted while the previous response was running. It
427
+ // has already interrupted that response; process it directly, first
428
+ // clearing the prompt box as a normal send would.
429
+ input = this.autoSubmit.input;
430
+ spans = this.autoSubmit.spans;
431
+ this.autoSubmit = null;
432
+ dockRenderPrompt("", [], "You", "❯", hints);
433
+ }
434
+ else {
435
+ const got = await askBottomPrompt("You", "❯", this.promptHistory, hints, this.lastPrompt);
436
+ input = got[0];
437
+ spans = got[1];
438
+ if (!input.trim())
439
+ continue;
440
+ }
415
441
  this.interruptPending = false;
416
442
  this.promptHistory.push(input);
417
443
  const lower = input.trim().toLowerCase();
@@ -463,18 +489,25 @@ export class CLI {
463
489
  continue;
464
490
  }
465
491
  dockAppendContent(`\x1b[1;36m❯\x1b[0m ${userDisplayText(input, spans)}\n`);
466
- // Empty the prompt field as soon as the prompt is sent. Only a draft
467
- // typed while the query runs is restored into the next box.
492
+ // Empty the prompt field as soon as the prompt is sent. A draft typed
493
+ // while the query runs is rendered into the box and restored into the
494
+ // next one; Enter submits it as a new prompt.
468
495
  this.lastPrompt = "";
469
496
  queryStarted = true;
470
- const stopCapture = startDraftCapture(() => engine.interrupt());
497
+ const stopCapture = startDraftCapture(() => engine.interrupt(), (buf, sp) => dockRenderPrompt(buf, sp, "You", "❯", hints));
471
498
  try {
472
499
  await engine.executeQuery(input, this.inputItems, this.story, spans);
473
500
  }
474
501
  finally {
475
502
  const captured = stopCapture();
476
- if (captured.trim())
477
- this.lastPrompt = captured;
503
+ if (captured.submitted && captured.draft.trim()) {
504
+ // The user pressed Enter during the response: interrupt it and queue
505
+ // the new prompt so the next loop iteration sends it (silently).
506
+ this.autoSubmit = { input: captured.draft, spans: [] };
507
+ }
508
+ else if (captured.draft.trim()) {
509
+ this.lastPrompt = captured.draft;
510
+ }
478
511
  queryStarted = false;
479
512
  }
480
513
  this.saveSession(engine);
@@ -488,6 +521,24 @@ export class CLI {
488
521
  }
489
522
  if (err?.message === "interrupt") {
490
523
  const wasActive = queryStarted || engine.inQuery;
524
+ // A prompt submitted during the response was queued in `autoSubmit`
525
+ // (by the finally above). Interrupt silently — no "Interrupted agent"
526
+ // alert — and let the next iteration process the new prompt (the loop
527
+ // top consumes `autoSubmit`).
528
+ if (this.autoSubmit && this.autoSubmit.input.trim()) {
529
+ engine.inQuery = false;
530
+ stripOrphanCalls(this.inputItems);
531
+ if (this.inputItems.length &&
532
+ this.inputItems[this.inputItems.length - 1]["role"] === "user") {
533
+ this.inputItems.pop();
534
+ }
535
+ if (this.story.length &&
536
+ this.story[this.story.length - 1]["type"] === "user") {
537
+ this.story.pop();
538
+ }
539
+ this.interruptPending = false;
540
+ continue;
541
+ }
491
542
  if (wasActive && !engine.queryHasOutput && !engine.queryCalledTool) {
492
543
  dockAppendContent(aiMarkdown("What else can I help you with?") + "\n");
493
544
  }
package/dist/ui.js CHANGED
@@ -39,14 +39,18 @@ export function waitRawKey() {
39
39
  });
40
40
  }
41
41
  /**
42
- * Capture typed input while a query runs (the prompt box is torn down during
43
- * streaming). Keystrokes are accumulated into a draft that the next prompt box
44
- * is pre-filled with, so nothing the user types while the AI is busy is lost.
45
- * Returns a stop() function returning the captured draft (may be empty).
42
+ * Capture typed input while a query runs (the prompt box is docked during
43
+ * streaming). Keystrokes are accumulated into a draft AND rendered into the
44
+ * docked prompt box via `renderBox`, so the user sees their input live while
45
+ * the AI is busy. Enter submits the draft: it interrupts the running query
46
+ * (as if Ctrl+C) and flags `submitted`, so the caller can send the new prompt.
47
+ * Returns a stop() function returning `{ draft, submitted }`.
46
48
  */
47
- export function startDraftCapture(onInterrupt) {
49
+ export function startDraftCapture(onInterrupt, renderBox) {
48
50
  let draft = "";
51
+ let pasteSpans = [];
49
52
  let active = true;
53
+ let submitted = false;
50
54
  const onKey = (str, key) => {
51
55
  if (!active)
52
56
  return;
@@ -56,14 +60,24 @@ export function startDraftCapture(onInterrupt) {
56
60
  }
57
61
  if (key && key.ctrl && (key.name === "d" || key.name === "z"))
58
62
  return;
63
+ if (isEnterKey(str, key)) {
64
+ if (draft.trim()) {
65
+ submitted = true;
66
+ active = false;
67
+ onInterrupt();
68
+ }
69
+ return;
70
+ }
71
+ if (key && (key.name === "enter" || key.name === "linefeed"))
72
+ return;
59
73
  if (key && key.name === "backspace") {
60
74
  draft = draft.slice(0, -1);
75
+ renderBox?.(draft, pasteSpans);
61
76
  return;
62
77
  }
63
- if (key && (key.name === "return" || key.name === "enter"))
64
- return;
65
78
  if (str && !key?.ctrl && !key?.meta) {
66
79
  draft += str.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
80
+ renderBox?.(draft, pasteSpans);
67
81
  }
68
82
  };
69
83
  readline.emitKeypressEvents(process.stdin);
@@ -81,7 +95,7 @@ export function startDraftCapture(onInterrupt) {
81
95
  /* ignore */
82
96
  }
83
97
  process.stdin.pause();
84
- return draft;
98
+ return { draft, submitted };
85
99
  };
86
100
  }
87
101
  // ---------------------------------------------------------------------------
@@ -568,6 +582,13 @@ let dockBoxRows = 0;
568
582
  let dockGap = false;
569
583
  let dockTransient = false;
570
584
  let dockFrameLines = [];
585
+ /**
586
+ * How many rows the caret sits BELOW boxTop when the prompt box is being
587
+ * live-edited during a query (0 = cursor parked at boxTop / transient, the
588
+ * normal dock invariant). dockEraseBox must know this to erase the box from
589
+ * the caret's position instead of assuming the cursor is at boxTop.
590
+ */
591
+ let dockCaretBelow = 0;
571
592
  export function dockIsDocked() {
572
593
  return docked;
573
594
  }
@@ -585,12 +606,14 @@ export function dockSetInactive() {
585
606
  dockGap = false;
586
607
  dockTransient = false;
587
608
  dockFrameLines = [];
609
+ dockCaretBelow = 0;
588
610
  }
589
611
  export function dockEnterDocked() {
590
612
  docked = true;
591
613
  dockRel = 0;
592
614
  dockGap = false;
593
615
  dockTransient = false;
616
+ dockCaretBelow = 0;
594
617
  }
595
618
  /** Erase the docked box and leave the cursor at boxTop with the gap above it,
596
619
  * so a pager/replay can write there and a later prompt re-docks cleanly. */
@@ -639,12 +662,18 @@ export function dockTransientStart(mode = "blank") {
639
662
  dockTransient = true;
640
663
  }
641
664
  /** Erase the box and any transient rows above it, leaving the cursor at boxTop
642
- * with the gap row intact above. */
665
+ * with the gap row intact above. Handles the cursor being parked at boxTop,
666
+ * above it (transient), or inside it at the live-edit caret. */
643
667
  function dockEraseBox() {
644
668
  if (!docked || dockBoxRows < 1)
645
669
  return;
646
670
  const rel = dockRel;
647
- process.stdout.write(`\x1b[${dockBoxRows - 1 + rel}B`);
671
+ const caret = dockCaretBelow;
672
+ // Move from the cursor to the box's bottom row, then erase the box upward.
673
+ // cursor at boxTop - rel (transient): go down (dockBoxRows - 1 + rel)
674
+ // cursor at boxTop + caret (live caret): go down (dockBoxRows - 1 - caret)
675
+ const down = caret > 0 ? dockBoxRows - 1 - caret : dockBoxRows - 1 + rel;
676
+ process.stdout.write(`\x1b[${down}B`);
648
677
  for (let i = 0; i < dockBoxRows; i++) {
649
678
  process.stdout.write("\r\x1b[K");
650
679
  if (i < dockBoxRows - 1)
@@ -657,6 +686,7 @@ function dockEraseBox() {
657
686
  }
658
687
  docked = false;
659
688
  dockRel = 0;
689
+ dockCaretBelow = 0;
660
690
  dockGap = true;
661
691
  dockTransient = false;
662
692
  }
@@ -674,6 +704,7 @@ function dockEraseBoxKeep() {
674
704
  process.stdout.write(`\x1b[${dockRel}A`);
675
705
  docked = false;
676
706
  dockRel = 0;
707
+ dockCaretBelow = 0;
677
708
  dockGap = true;
678
709
  dockTransient = false;
679
710
  }
@@ -685,10 +716,31 @@ function dockRenderBox() {
685
716
  process.stdout.write("\n\n" + dockFrameLines.join("\n"));
686
717
  process.stdout.write(`\x1b[${dockBoxRows - 1}A\r`);
687
718
  dockRel = 0;
719
+ dockCaretBelow = 0;
688
720
  docked = true;
689
721
  dockGap = false;
690
722
  dockTransient = false;
691
723
  }
724
+ /**
725
+ * Redraw the docked prompt box with the given buffer/caret, keeping whatever
726
+ * content already streams above it. Used to make the prompt field live-edit
727
+ * while a query runs. Erases the box (and any transient row above it),
728
+ * writes the new frame, and parks the cursor at the caret. Preserves exactly
729
+ * one blank row between the last content and the box.
730
+ */
731
+ export function dockRenderPrompt(buffer, pasteSpans, prefix, label, hints) {
732
+ if (!docked || dockBoxRows < 1)
733
+ return;
734
+ const { frame, cursorRow, cursorCol, totalRows } = renderBufferWithCursor(buffer, pasteSpans, prefix, label, buffer.length, hints);
735
+ dockEraseBox();
736
+ process.stdout.write(frame);
737
+ // Park the cursor at the caret inside the box (cursorRow is 1-indexed, so
738
+ // the caret sits cursorRow rows below boxTop).
739
+ process.stdout.write(`\x1b[${totalRows - 1 - cursorRow}A\r\x1b[${cursorCol}C`);
740
+ dockSetFrame(frame, totalRows);
741
+ dockEnterDocked();
742
+ dockCaretBelow = cursorRow;
743
+ }
692
744
  /** Commit content at the box top, pushing the box down, preserving exactly one
693
745
  * blank row between the content and the box. */
694
746
  export function dockAppendContent(text) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxecli/oxe",
3
- "version": "1.0.97",
3
+ "version": "1.0.98",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },