@oxecli/oxe 1.0.88 → 1.0.89

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
@@ -18,6 +18,7 @@ export class CLI {
18
18
  story = [];
19
19
  sessionId = null;
20
20
  promptHistory = [];
21
+ lastPrompt = "";
21
22
  interruptPending = false;
22
23
  constructor() {
23
24
  clearScreen();
@@ -395,10 +396,50 @@ export class CLI {
395
396
  const used = estimateTokens(this.inputItems);
396
397
  const budget = max_context_tokens - context_overhead_margin;
397
398
  const pct = Math.max(0, Math.min(100, Math.round(((budget - used) / budget) * 100)));
399
+ // Queries run through onSubmit so the prompt box stays focused and
400
+ // editable while the AI streams its reply and tool calls above it.
401
+ // Commands (slash inputs) settle back to this loop for handling.
402
+ const onSubmit = async (input, spans) => {
403
+ this.interruptPending = false;
404
+ this.promptHistory.push(input);
405
+ this.lastPrompt = input;
406
+ dockAppendContent(`\x1b[1;36m❯\x1b[0m ${userDisplayText(input, spans)}\n`);
407
+ queryStarted = true;
408
+ try {
409
+ await engine.executeQuery(input, this.inputItems, this.story, spans);
410
+ }
411
+ catch (qerr) {
412
+ if (qerr?.message === "interrupt") {
413
+ if (!engine.queryHasOutput && !engine.queryCalledTool) {
414
+ dockAppendContent(aiMarkdown("What else can I help you with?") + "\n");
415
+ }
416
+ engine.inQuery = false;
417
+ stripOrphanCalls(this.inputItems);
418
+ if (this.inputItems.length &&
419
+ this.inputItems[this.inputItems.length - 1]["role"] === "user") {
420
+ this.inputItems.pop();
421
+ }
422
+ if (this.story.length &&
423
+ this.story[this.story.length - 1]["type"] === "user") {
424
+ this.story.pop();
425
+ }
426
+ this.interruptPending = true;
427
+ dockAppendContent("\x1b[90mInterrupted agent. Press Ctrl+C again to exit, or type a new prompt.\x1b[0m\n");
428
+ }
429
+ else if (qerr?.message !== "eof") {
430
+ throw qerr;
431
+ }
432
+ }
433
+ finally {
434
+ queryStarted = false;
435
+ }
436
+ this.saveSession(engine);
437
+ };
438
+ const onInterrupt = () => engine.interrupt();
398
439
  const [input, spans] = await askBottomPrompt("You", "❯", this.promptHistory, {
399
440
  left: "\x1b[90mPress \x1b[1;36mCtrl+C\x1b[0m\x1b[90m to interrupt\x1b[0m",
400
441
  right: `\x1b[1;36m${pct}%\x1b[0m \x1b[90muntil auto-compact\x1b[0m`,
401
- });
442
+ }, this.lastPrompt, onSubmit, onInterrupt);
402
443
  if (!input.trim())
403
444
  continue;
404
445
  this.interruptPending = false;
@@ -450,11 +491,6 @@ export class CLI {
450
491
  }
451
492
  continue;
452
493
  }
453
- dockAppendContent(`\x1b[1;36m❯\x1b[0m ${userDisplayText(input, spans)}\n`);
454
- queryStarted = true;
455
- await engine.executeQuery(input, this.inputItems, this.story, spans);
456
- queryStarted = false;
457
- this.saveSession(engine);
458
494
  }
459
495
  catch (err) {
460
496
  if (err?.message === "eof") {
package/dist/config.js CHANGED
@@ -191,7 +191,7 @@ export async function loadOrPrompt() {
191
191
  break;
192
192
  }
193
193
  else {
194
- process.stdout.write(`\x1b[31mAuthentication Error:\x1b[0m ${validation.error}\n\n`);
194
+ process.stdout.write(`\x1b[31mAuthentication Error:\x1b[0m ${validation.error}\n`);
195
195
  api_key = "";
196
196
  }
197
197
  }
package/dist/engine.js CHANGED
@@ -542,7 +542,7 @@ export class InferenceEngine {
542
542
  // Print the tool label the instant the command starts, then show a
543
543
  // "╰─ Working..." dots line that swaps to the real result in place.
544
544
  dockAppendContent(`${started}\n`);
545
- dockTransientStart();
545
+ dockTransientStart("flush");
546
546
  const toolSpinner = new Spinner();
547
547
  toolSpinner.startDots("\x1b[90m╰─\x1b[0m Working");
548
548
  const rawResult = await this.runTool(c.name, c.arguments);
@@ -564,10 +564,10 @@ export class InferenceEngine {
564
564
  // produced a diff it renders directly below the action line with no
565
565
  // gap; otherwise the dock gap separates the action from what follows.
566
566
  toolSpinner.stop();
567
- dockReplaceTransient(action + "\n");
567
+ dockReplaceTransient(action);
568
568
  if (diff) {
569
- dockTransientStart();
570
- dockReplaceTransient(diff + "\n");
569
+ dockTransientStart("flush");
570
+ dockReplaceTransient(diff);
571
571
  }
572
572
  const truncatedResult = c.name === "read_file"
573
573
  ? truncateToolOutput(rawResult, max_read_file_stored_chars)
package/dist/tools.js CHANGED
@@ -31,6 +31,36 @@ function truncateDiffLine(line) {
31
31
  }
32
32
  return line;
33
33
  }
34
+ /**
35
+ * Recolor bracket syntax characters (`( ) [ ] { }`) inside an already-ANSI
36
+ * rendered string to `fg`, restoring the row's white base after each bracket.
37
+ * ANSI escape sequences are skipped so the injected color codes are never
38
+ * corrupted. Used so a deleted (red) diff row's bracket syntax matches the
39
+ * red of its line number instead of inheriting the syntax highlighter's color.
40
+ */
41
+ function recolorBrackets(text, fg, base) {
42
+ let out = "";
43
+ let i = 0;
44
+ while (i < text.length) {
45
+ if (text[i] === "\x1b") {
46
+ const m = text.slice(i).match(/^\x1b\[[0-9;]*[a-zA-Z]/);
47
+ if (m) {
48
+ out += m[0];
49
+ i += m[0].length;
50
+ continue;
51
+ }
52
+ }
53
+ const ch = text[i];
54
+ if (ch === "(" || ch === ")" || ch === "[" || ch === "]" || ch === "{" || ch === "}") {
55
+ out += fg + ch + base;
56
+ }
57
+ else {
58
+ out += ch;
59
+ }
60
+ i++;
61
+ }
62
+ return out;
63
+ }
34
64
  // Diff output is deferred (not written straight to stdout) so it doesn't
35
65
  // collide with the tool spinner that is still animating while the tool runs.
36
66
  const pendingDiffOutput = [];
@@ -148,8 +178,14 @@ function displayDiff(pathName, oldContent, newContent) {
148
178
  // the whole body.
149
179
  const raw = highlightCodeLine(v.body);
150
180
  const body = truncateStyled(raw.replace(/\x1b\[0m/g, `\x1b[0m${bg}\x1b[97m`), bodyMax);
181
+ // Deleted rows: recolor bracket syntax to the same red as the line
182
+ // number (fg) so brackets match the row's deleted color. Added rows keep
183
+ // their green highlighter as-is.
184
+ const shown = isAdd
185
+ ? body
186
+ : recolorBrackets(body, fg, "\x1b[97m");
151
187
  const fill = Math.max(0, termW - ind.length - numW - 4 - plainLen(body));
152
- pendingDiffOutput.push(`${ind}${bg}${fg}${numStr} ${sign} \x1b[0m${bg}\x1b[97m${body}${bg}\x1b[97m${" ".repeat(fill)}\x1b[0m`);
188
+ pendingDiffOutput.push(`${ind}${bg}${fg}${numStr} ${sign} \x1b[0m${bg}\x1b[97m${shown}${bg}\x1b[97m${" ".repeat(fill)}\x1b[0m`);
153
189
  }
154
190
  else {
155
191
  const raw = v.kind === "\\" ? v.body : highlightCodeLine(v.body);
package/dist/ui.js CHANGED
@@ -293,6 +293,12 @@ export function markdownToAnsi(text) {
293
293
  out.push(`\x1b[90m${"─".repeat(Math.min(width, 60))}\x1b[0m`);
294
294
  continue;
295
295
  }
296
+ // Collapse consecutive blank lines to a single empty row (never a leading one)
297
+ if (!raw.trim()) {
298
+ if (out.length && out[out.length - 1] !== "")
299
+ out.push("");
300
+ continue;
301
+ }
296
302
  let line = raw;
297
303
  // Headers (render as styled headings with the '#' markers stripped)
298
304
  const h1 = line.match(/^#\s+(.+)$/);
@@ -551,17 +557,20 @@ export function dockTearDown() {
551
557
  dockFrameLines = [];
552
558
  }
553
559
  }
554
- /** Make a content row two rows above the box for a transient spinner: push the
555
- * box down one content row, then park the cursor on it with the blank gap
556
- * row (boxTop-1) preserved between it and the box. Idempotent while parked. */
557
- export function dockTransientStart() {
560
+ /** Park a transient spinner two rows above the box (dockRel=2), keeping boxTop-1
561
+ * as the single blank gap. "flush" parks the spinner directly under the
562
+ * preceding content (label, spinner, blank, box); "blank" gives it its own
563
+ * blank row above (echo, blank, spinner, blank, box). Idempotent while parked. */
564
+ export function dockTransientStart(mode = "blank") {
558
565
  if (!docked || dockBoxRows < 1)
559
566
  return;
560
567
  if (dockTransient)
561
568
  return;
562
- dockEraseBox();
563
- dockRenderBox();
564
- process.stdout.write("\x1b[2A\r");
569
+ const insert = mode === "flush" ? 1 : 2;
570
+ process.stdout.write(`\x1b[${insert}L`);
571
+ if (mode === "flush")
572
+ process.stdout.write("\x1b[1A");
573
+ process.stdout.write("\r");
565
574
  dockRel = 2;
566
575
  dockTransient = true;
567
576
  }
@@ -587,35 +596,9 @@ function dockEraseBox() {
587
596
  dockGap = true;
588
597
  dockTransient = false;
589
598
  }
590
- function dockEraseBoxKeep() {
591
- if (!docked || dockBoxRows < 1)
592
- return;
593
- process.stdout.write(`\x1b[${dockBoxRows - 1 + dockRel}B`);
594
- for (let i = 0; i < dockBoxRows; i++) {
595
- process.stdout.write("\r\x1b[K");
596
- if (i < dockBoxRows - 1)
597
- process.stdout.write("\x1b[1A");
598
- }
599
- process.stdout.write(`\x1b[${dockRel}A`);
600
- docked = false;
601
- dockRel = 0;
602
- dockGap = true;
603
- dockTransient = false;
604
- }
605
- /** Write exactly one blank row then the box frame below the current row, and
606
- * park the cursor at the box top. */
607
- function dockRenderBox() {
608
- if (dockBoxRows < 1)
609
- return;
610
- process.stdout.write("\n\n" + dockFrameLines.join("\n"));
611
- process.stdout.write(`\x1b[${dockBoxRows - 1}A\r`);
612
- dockRel = 0;
613
- docked = true;
614
- dockGap = false;
615
- dockTransient = false;
616
- }
617
- /** Commit content at the box top, pushing the box down, preserving exactly one
618
- * blank row between the content and the box. */
599
+ /** Commit content at the box top: insert `rows+1` lines to push the box down
600
+ * (no erase/redraw), write the body, leave exactly one blank row between the
601
+ * content and the box, and park the cursor at the new boxTop. */
619
602
  export function dockAppendContent(text) {
620
603
  if (!text)
621
604
  return;
@@ -623,15 +606,18 @@ export function dockAppendContent(text) {
623
606
  process.stdout.write(text);
624
607
  return;
625
608
  }
626
- dockEraseBox();
627
- process.stdout.write(text);
628
- const tb = displayRows(text) - displayRows(text.replace(/\n+$/, ""));
629
- if (tb > 0)
630
- process.stdout.write(`\x1b[${tb}A`);
631
- dockRenderBox();
609
+ const body = text.replace(/\n+$/, "");
610
+ if (!body)
611
+ return;
612
+ const rows = displayRows(body);
613
+ process.stdout.write(`\x1b[${rows + 1}L`);
614
+ process.stdout.write(body + "\n\r");
615
+ process.stdout.write("\x1b[1B\r");
632
616
  }
633
- /** Replace a transient spinner row (two rows above the box) with content,
634
- * then re-dock, keeping exactly one blank row between content and box. */
617
+ /** Replace a transient spinner row (dockRel=2, two rows above the box) with
618
+ * real (possibly multi-row) content: erase the spinner line, push the box
619
+ * down by `rows-1` if the content is taller than the transient row, write the
620
+ * content, and park the cursor at the boxTop with one blank row above it. */
635
621
  export function dockReplaceTransient(text) {
636
622
  if (!text)
637
623
  return;
@@ -639,12 +625,22 @@ export function dockReplaceTransient(text) {
639
625
  process.stdout.write(text);
640
626
  return;
641
627
  }
642
- dockEraseBoxKeep();
643
- process.stdout.write(text);
644
- const tb = displayRows(text) - displayRows(text.replace(/\n+$/, ""));
645
- if (tb > 0)
646
- process.stdout.write(`\x1b[${tb}A`);
647
- dockRenderBox();
628
+ if (!dockTransient) {
629
+ dockAppendContent(text);
630
+ return;
631
+ }
632
+ const body = text.replace(/\n+$/, "");
633
+ if (!body)
634
+ return;
635
+ const rows = displayRows(body);
636
+ const push = rows + 1 - dockRel;
637
+ process.stdout.write("\r\x1b[K");
638
+ if (push > 0)
639
+ process.stdout.write(`\x1b[${push}L`);
640
+ process.stdout.write(body);
641
+ process.stdout.write("\x1b[2B\r");
642
+ dockRel = 0;
643
+ dockTransient = false;
648
644
  }
649
645
  // ---------------------------------------------------------------------------
650
646
  // Cursor & Spinner
@@ -925,9 +921,23 @@ export function cursorLineCol(buffer, cursor) {
925
921
  function endsWithWs(s) {
926
922
  return s.length > 0 && /[ \t\n]/.test(s[s.length - 1]);
927
923
  }
924
+ /**
925
+ * Whether a spacer should be inserted between the previous segment and the
926
+ * current one. A space is added when the previous segment doesn't already end
927
+ * in whitespace — EXCEPT for non-block (inline) pastes, which keep their
928
+ * leading space but never force a trailing space after them. Collapsed pasted
929
+ * blocks always get spaced on both sides.
930
+ */
931
+ function joinNeedsSpace(prev) {
932
+ if (endsWithWs(prev[2]))
933
+ return false;
934
+ if (prev[1] === "text" && prev[3])
935
+ return false; // non-block inline paste
936
+ return true;
937
+ }
928
938
  export function splitBlocks(buffer, pasteSpans) {
929
939
  if (!buffer)
930
- return [["", "text", ""]];
940
+ return [["", "text", "", false]];
931
941
  const pts = new Set([0, buffer.length]);
932
942
  for (const [s, e] of pasteSpans) {
933
943
  pts.add(s);
@@ -944,10 +954,10 @@ export function splitBlocks(buffer, pasteSpans) {
944
954
  const text = buffer.slice(a, b);
945
955
  if (isPaste(a, b) && shouldCollapsePaste(text)) {
946
956
  const n = text.split("\n").length;
947
- segs.push(["", "collapsed", `[Pasted text, ${n} lines]`]);
957
+ segs.push(["", "collapsed", `[Pasted text, ${n} lines]`, true]);
948
958
  }
949
959
  else {
950
- segs.push(["", "text", text]);
960
+ segs.push(["", "text", text, isPaste(a, b)]);
951
961
  }
952
962
  }
953
963
  return segs;
@@ -1078,8 +1088,8 @@ export function renderBufferWithCursor(buffer, pasteSpans, prefix, label, cursor
1078
1088
  }
1079
1089
  }
1080
1090
  for (let i = 0; i < segs.length; i++) {
1081
- const [, kind, disp] = segs[i];
1082
- if (i && !endsWithWs(segs[i - 1][2]))
1091
+ const [, kind, disp, pasted] = segs[i];
1092
+ if (i && joinNeedsSpace(segs[i - 1]))
1083
1093
  runs.push({ text: " ", style: "" });
1084
1094
  if (i === target) {
1085
1095
  if (kind === "collapsed") {
@@ -1153,7 +1163,7 @@ function isEnterKey(str, key) {
1153
1163
  seq === "\x1bOM" // application keypad
1154
1164
  );
1155
1165
  }
1156
- export function askBottomPrompt(label = "You", prefix = "❯", history = [], hints) {
1166
+ export function askBottomPrompt(label = "You", prefix = "❯", history = [], hints, initialBuffer = "", onSubmit, onInterrupt) {
1157
1167
  return new Promise((resolve, reject) => {
1158
1168
  const isTTY = process.stdin.isTTY;
1159
1169
  if (!isTTY) {
@@ -1169,8 +1179,8 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = [], hin
1169
1179
  });
1170
1180
  return;
1171
1181
  }
1172
- let buffer = "";
1173
- let cursor = 0;
1182
+ let buffer = initialBuffer;
1183
+ let cursor = buffer.length;
1174
1184
  let pasteSpans = [];
1175
1185
  const hist = history.slice();
1176
1186
  let histIdx = hist.length;
@@ -1191,6 +1201,9 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = [], hin
1191
1201
  let lastCursorRow = 0;
1192
1202
  let lastTotalRows = 0;
1193
1203
  let prevFrameLines = null;
1204
+ // True while an onSubmit query is running. While it runs the box stays
1205
+ // focused and editable; Enter won't re-submit and Ctrl+C interrupts it.
1206
+ let submitting = false;
1194
1207
  const clearBox = () => {
1195
1208
  process.stdout.write(`\x1b[${lastCursorRow}A`);
1196
1209
  for (let i = 0; i < lastTotalRows; i++) {
@@ -1243,12 +1256,14 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = [], hin
1243
1256
  process.stdout.write("\n");
1244
1257
  }
1245
1258
  prevFrameLines = newLines;
1246
- lastCursorRow = cursorRow;
1259
+ lastCursorRow = 0;
1247
1260
  lastTotalRows = totalRows;
1248
1261
  dockSetFrame(frame, totalRows);
1249
1262
  dockEnterDocked();
1250
- const up = totalRows - 1 - cursorRow;
1251
- process.stdout.write(`\x1b[${up}A\r\x1b[${cursorCol}C`);
1263
+ // Park the cursor at the box top so concurrent dock content (AI
1264
+ // streaming, tool results) inserts above the box rather than into it.
1265
+ const up = totalRows - 1;
1266
+ process.stdout.write(`\x1b[${up}A\r`);
1252
1267
  }
1253
1268
  else {
1254
1269
  process.stdout.write(`\x1b[${lastCursorRow}A\r`);
@@ -1262,13 +1277,11 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = [], hin
1262
1277
  process.stdout.write("\n");
1263
1278
  }
1264
1279
  const atLine = maxLen - 1;
1265
- if (atLine > cursorRow)
1266
- process.stdout.write(`\x1b[${atLine - cursorRow}A`);
1267
- else if (atLine < cursorRow)
1268
- process.stdout.write(`\x1b[${cursorRow - atLine}B`);
1269
- process.stdout.write(`\r\x1b[${cursorCol}C`);
1280
+ // Return the cursor to the box top so concurrent dock content keeps
1281
+ // inserting above the box.
1282
+ process.stdout.write(`\x1b[${atLine}A\r`);
1270
1283
  prevFrameLines = newLines;
1271
- lastCursorRow = cursorRow;
1284
+ lastCursorRow = 0;
1272
1285
  lastTotalRows = totalRows;
1273
1286
  dockSetFrame(frame, totalRows);
1274
1287
  }
@@ -1553,6 +1566,12 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = [], hin
1553
1566
  pasteBurstTimer = null;
1554
1567
  }
1555
1568
  if (key && key.ctrl && key.name === "c") {
1569
+ // While a query is running, Ctrl+C interrupts the query instead of
1570
+ // leaving the prompt (the box stays focused for editing).
1571
+ if (submitting && onInterrupt) {
1572
+ onInterrupt();
1573
+ return;
1574
+ }
1556
1575
  settle(new Error("interrupt"), true);
1557
1576
  return;
1558
1577
  }
@@ -1593,9 +1612,31 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = [], hin
1593
1612
  return;
1594
1613
  }
1595
1614
  if (isEnterKey(str, key)) {
1596
- if (buffer.trim()) {
1597
- settle([buffer, pasteSpans], false);
1615
+ const trimmed = buffer.trim();
1616
+ if (!trimmed)
1617
+ return;
1618
+ // While a query is running the box stays focused and editable; Enter is
1619
+ // ignored (even for commands) so the query finishes cleanly first.
1620
+ if (submitting)
1621
+ return;
1622
+ // With an onSubmit handler, ordinary prompts are submitted in place so
1623
+ // the box stays focused and editable while the query streams above it.
1624
+ // Slash commands (and any prompt when no handler is wired) settle so the
1625
+ // caller's existing command loop handles them.
1626
+ if (onSubmit && !trimmed.startsWith("/")) {
1627
+ submitting = true;
1628
+ const submitted = buffer;
1629
+ const submittedSpans = pasteSpans.slice();
1630
+ Promise.resolve()
1631
+ .then(() => onSubmit(submitted, submittedSpans))
1632
+ .then(() => {
1633
+ submitting = false;
1634
+ }, () => {
1635
+ submitting = false;
1636
+ });
1637
+ return;
1598
1638
  }
1639
+ settle([buffer, pasteSpans], false);
1599
1640
  return;
1600
1641
  }
1601
1642
  if (key && key.name === "backspace") {
@@ -1675,7 +1716,7 @@ export function userDisplayText(payload, pasteSpans) {
1675
1716
  let out = "";
1676
1717
  for (let i = 0; i < segs.length; i++) {
1677
1718
  const [, kind, disp] = segs[i];
1678
- if (i && !endsWithWs(segs[i - 1][2]))
1719
+ if (i && joinNeedsSpace(segs[i - 1]))
1679
1720
  out += " ";
1680
1721
  out += kind === "collapsed" ? `\x1b[1;36m${disp}\x1b[0m` : disp;
1681
1722
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxecli/oxe",
3
- "version": "1.0.88",
3
+ "version": "1.0.89",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },