@oxecli/oxe 1.0.97 → 1.0.99

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.
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, renderWarning, 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();
@@ -241,7 +248,7 @@ export class CLI {
241
248
  process.stdout.write(mutedMarkdown(text) + "\n");
242
249
  }
243
250
  else if (typ === "warning") {
244
- process.stdout.write(`\x1b[33m${text}\x1b[0m\n`);
251
+ renderWarning(text);
245
252
  }
246
253
  else if (typ === "error") {
247
254
  process.stdout.write(`\x1b[31m${text}\x1b[0m\n`);
@@ -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/config.js CHANGED
@@ -28,6 +28,9 @@ export const context_overhead_margin = 32_000;
28
28
  export const compact_keep_recent_turns = 4;
29
29
  export const max_summary_source_chars = 200_000;
30
30
  export const max_output_tokens = 16384;
31
+ /** Cap on how many times a single turn may be continued past the output-token
32
+ * cap before we give up extending it (prevents runaway generation). */
33
+ export const max_output_continuations = 6;
31
34
  export const max_empty_retries = 2;
32
35
  export const max_diff_source_chars = 1_000_000;
33
36
  export const max_grep_file_bytes = 20 * 1024 * 1024;
package/dist/engine.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import OpenAI from "openai";
2
- import { max_output_tokens, max_empty_retries, max_agent_steps, max_context_tokens, context_overhead_margin, compact_keep_recent_turns, max_summary_source_chars, max_read_file_stored_chars, } from "./config.js";
2
+ import { max_output_tokens, max_empty_retries, max_output_continuations, max_agent_steps, max_context_tokens, context_overhead_margin, compact_keep_recent_turns, max_summary_source_chars, max_read_file_stored_chars, } from "./config.js";
3
3
  import { getSystemPrompt } from "./system.js";
4
4
  import { buildTools, truncateToolOutput, toolReadFile, toolWriteFile, toolEditFile, toolBash, toolGlob, toolGrep, toolLoadSkill, } from "./tools.js";
5
- import { mutedMarkdown, aiMarkdown, tickDuration, safeCommitPoint, printAiChunk, toolCallLabel, formatToolResult, renderPanel, Spinner, hideCursor, dockAppendContent, dockReplaceTransient, dockTransientStart, } from "./ui.js";
5
+ import { mutedMarkdown, aiMarkdown, tickDuration, safeCommitPoint, printAiChunk, toolCallLabel, formatToolResult, renderPanel, renderWarning, Spinner, hideCursor, dockAppendContent, dockReplaceTransient, dockTransientStart, } from "./ui.js";
6
6
  import { reportUsage } from "./api.js";
7
7
  import { stripOrphanCalls, persistCompactionSummary, toolOutputFailed, } from "./sessions.js";
8
8
  import { takePendingDiffOutput } from "./tools.js";
@@ -315,10 +315,13 @@ export class InferenceEngine {
315
315
  const text = response?.output_text ?? content;
316
316
  if (text.trim())
317
317
  story.push({ type: "assistant", text });
318
- let incompleteReason = null;
318
+ // The incomplete reason must always be captured — not only in the empty
319
+ // case — so a response truncated by the output-token cap is never mistaken
320
+ // for a complete answer. `emptyMessage` is only relevant when nothing at
321
+ // all (text or tool call) was produced.
322
+ const incompleteReason = this.incompleteReason(response);
319
323
  let emptyMessage = null;
320
324
  if (!text.trim() && calls.length === 0) {
321
- incompleteReason = this.incompleteReason(response);
322
325
  emptyMessage = this.diagnoseEmptyResponse(response, sawReasoning);
323
326
  }
324
327
  return {
@@ -428,6 +431,7 @@ export class InferenceEngine {
428
431
  let conversation = [...inputItems];
429
432
  [conversation] = await this.compactIfNeeded(conversation, inputItems, story);
430
433
  let emptyRetries = 0;
434
+ let outputContinuations = 0;
431
435
  const retryPrompts = [];
432
436
  const stats = {
433
437
  thinking: 0,
@@ -524,6 +528,20 @@ export class InferenceEngine {
524
528
  prevId = null;
525
529
  pending = conversation;
526
530
  }
531
+ // A response cut off by the output-token cap is NOT a final answer —
532
+ // treating it as one is what returned force-truncated replies. Instead,
533
+ // continue generating from the previous response via `previous_response_id`
534
+ // until the response completes naturally. Bounded to prevent runaway
535
+ // output. Each continuation's output items are already committed above,
536
+ // so the full (concatenated) response is preserved in the transcript.
537
+ if (!compactedNow &&
538
+ incompleteReason === "max_output_tokens" &&
539
+ outputContinuations < max_output_continuations) {
540
+ outputContinuations++;
541
+ prevId = respId;
542
+ pending = [];
543
+ continue;
544
+ }
527
545
  if (emptyMessage !== null) {
528
546
  if (incompleteReason === "max_output_tokens" &&
529
547
  emptyRetries < max_empty_retries) {
@@ -617,12 +635,10 @@ export class InferenceEngine {
617
635
  // The preceding tool action already leaves the cursor on a blank row, so
618
636
  // no extra newline is needed above; renderPanel ends with its own "\n",
619
637
  // and the prompt adds one leading "\n" -> exactly one blank row below.
620
- const maxIterationMsg = "Max tool-call iterations reached for this turn. The work so far is " +
638
+ const maxIterationMsg = "Max tool-call iterations reached for this turn. The work so far is " +
621
639
  "saved. If you want the agent to keep going, type continue and the next " +
622
640
  "step will resume from where it left off.";
623
- renderPanel("[bold yellow]⚠ Max tool-call iterations reached for this turn.[/bold yellow]\n" +
624
- "[yellow]The work so far is saved. If you want the agent to keep going, type " +
625
- "[bold]continue[/bold] and the next step will resume from where it left off.[/yellow]", "Warning", "", false, "33");
641
+ renderWarning(maxIterationMsg);
626
642
  story.push({ type: "warning", text: maxIterationMsg });
627
643
  if (retryPrompts.length)
628
644
  dropRetryPrompts(conversation, inputItems, retryPrompts);
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
  // ---------------------------------------------------------------------------
@@ -465,6 +479,25 @@ function panelString(content, title = "", subtitle = "", expand = true, borderSt
465
479
  export function renderPanel(content, title = "", subtitle = "", expand = true, borderStyle = "90", titleAlign = "center") {
466
480
  dockAppendContent(panelString(content, title, subtitle, expand, borderStyle, titleAlign) + "\n");
467
481
  }
482
+ /** Warning sign with the text-presentation variation selector, so it renders
483
+ * as a monochrome icon (matching the app's ✓/✗ symbols) rather than a color
484
+ * emoji that varies by platform. */
485
+ export const WARNING_ICON = "\u26A0\uFE0E";
486
+ /**
487
+ * Render a warning as a framed panel (consistent shape whether shown live or
488
+ * replayed from a resumed session), using the text-presentation warning icon
489
+ * instead of an emoji. The first sentence is emphasized; the rest is plain.
490
+ */
491
+ export function renderWarning(message) {
492
+ const text = String(message ?? "").replace(/^[\s\u26A0\uFE0E]+/, "").trim();
493
+ const idx = text.indexOf(". ");
494
+ const head = idx === -1 ? text : text.slice(0, idx + 1);
495
+ const body = idx === -1 ? "" : text.slice(idx + 2).trim();
496
+ let content = `[bold yellow]${WARNING_ICON} ${head}[/bold yellow]`;
497
+ if (body)
498
+ content += `\n[yellow]${body}[/yellow]`;
499
+ renderPanel(content, "Warning", "", false, "33");
500
+ }
468
501
  // ---------------------------------------------------------------------------
469
502
  // Table Panel Rendering
470
503
  // ---------------------------------------------------------------------------
@@ -568,6 +601,13 @@ let dockBoxRows = 0;
568
601
  let dockGap = false;
569
602
  let dockTransient = false;
570
603
  let dockFrameLines = [];
604
+ /**
605
+ * How many rows the caret sits BELOW boxTop when the prompt box is being
606
+ * live-edited during a query (0 = cursor parked at boxTop / transient, the
607
+ * normal dock invariant). dockEraseBox must know this to erase the box from
608
+ * the caret's position instead of assuming the cursor is at boxTop.
609
+ */
610
+ let dockCaretBelow = 0;
571
611
  export function dockIsDocked() {
572
612
  return docked;
573
613
  }
@@ -585,12 +625,14 @@ export function dockSetInactive() {
585
625
  dockGap = false;
586
626
  dockTransient = false;
587
627
  dockFrameLines = [];
628
+ dockCaretBelow = 0;
588
629
  }
589
630
  export function dockEnterDocked() {
590
631
  docked = true;
591
632
  dockRel = 0;
592
633
  dockGap = false;
593
634
  dockTransient = false;
635
+ dockCaretBelow = 0;
594
636
  }
595
637
  /** Erase the docked box and leave the cursor at boxTop with the gap above it,
596
638
  * so a pager/replay can write there and a later prompt re-docks cleanly. */
@@ -639,12 +681,18 @@ export function dockTransientStart(mode = "blank") {
639
681
  dockTransient = true;
640
682
  }
641
683
  /** Erase the box and any transient rows above it, leaving the cursor at boxTop
642
- * with the gap row intact above. */
684
+ * with the gap row intact above. Handles the cursor being parked at boxTop,
685
+ * above it (transient), or inside it at the live-edit caret. */
643
686
  function dockEraseBox() {
644
687
  if (!docked || dockBoxRows < 1)
645
688
  return;
646
689
  const rel = dockRel;
647
- process.stdout.write(`\x1b[${dockBoxRows - 1 + rel}B`);
690
+ const caret = dockCaretBelow;
691
+ // Move from the cursor to the box's bottom row, then erase the box upward.
692
+ // cursor at boxTop - rel (transient): go down (dockBoxRows - 1 + rel)
693
+ // cursor at boxTop + caret (live caret): go down (dockBoxRows - 1 - caret)
694
+ const down = caret > 0 ? dockBoxRows - 1 - caret : dockBoxRows - 1 + rel;
695
+ process.stdout.write(`\x1b[${down}B`);
648
696
  for (let i = 0; i < dockBoxRows; i++) {
649
697
  process.stdout.write("\r\x1b[K");
650
698
  if (i < dockBoxRows - 1)
@@ -657,6 +705,7 @@ function dockEraseBox() {
657
705
  }
658
706
  docked = false;
659
707
  dockRel = 0;
708
+ dockCaretBelow = 0;
660
709
  dockGap = true;
661
710
  dockTransient = false;
662
711
  }
@@ -674,6 +723,7 @@ function dockEraseBoxKeep() {
674
723
  process.stdout.write(`\x1b[${dockRel}A`);
675
724
  docked = false;
676
725
  dockRel = 0;
726
+ dockCaretBelow = 0;
677
727
  dockGap = true;
678
728
  dockTransient = false;
679
729
  }
@@ -685,10 +735,31 @@ function dockRenderBox() {
685
735
  process.stdout.write("\n\n" + dockFrameLines.join("\n"));
686
736
  process.stdout.write(`\x1b[${dockBoxRows - 1}A\r`);
687
737
  dockRel = 0;
738
+ dockCaretBelow = 0;
688
739
  docked = true;
689
740
  dockGap = false;
690
741
  dockTransient = false;
691
742
  }
743
+ /**
744
+ * Redraw the docked prompt box with the given buffer/caret, keeping whatever
745
+ * content already streams above it. Used to make the prompt field live-edit
746
+ * while a query runs. Erases the box (and any transient row above it),
747
+ * writes the new frame, and parks the cursor at the caret. Preserves exactly
748
+ * one blank row between the last content and the box.
749
+ */
750
+ export function dockRenderPrompt(buffer, pasteSpans, prefix, label, hints) {
751
+ if (!docked || dockBoxRows < 1)
752
+ return;
753
+ const { frame, cursorRow, cursorCol, totalRows } = renderBufferWithCursor(buffer, pasteSpans, prefix, label, buffer.length, hints);
754
+ dockEraseBox();
755
+ process.stdout.write(frame);
756
+ // Park the cursor at the caret inside the box (cursorRow is 1-indexed, so
757
+ // the caret sits cursorRow rows below boxTop).
758
+ process.stdout.write(`\x1b[${totalRows - 1 - cursorRow}A\r\x1b[${cursorCol}C`);
759
+ dockSetFrame(frame, totalRows);
760
+ dockEnterDocked();
761
+ dockCaretBelow = cursorRow;
762
+ }
692
763
  /** Commit content at the box top, pushing the box down, preserving exactly one
693
764
  * blank row between the content and the box. */
694
765
  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.99",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },