@oxecli/oxe 1.0.88 → 1.0.90

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,8 +1201,12 @@ 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
- process.stdout.write(`\x1b[${lastCursorRow}A`);
1208
+ if (lastCursorRow > 0)
1209
+ process.stdout.write(`\x1b[${lastCursorRow}A`);
1196
1210
  for (let i = 0; i < lastTotalRows; i++) {
1197
1211
  process.stdout.write("\r\x1b[K");
1198
1212
  if (i < lastTotalRows - 1)
@@ -1215,7 +1229,9 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = [], hin
1215
1229
  dockSetInactive();
1216
1230
  }
1217
1231
  else {
1218
- process.stdout.write(`\x1b[${lastCursorRow}A\r`);
1232
+ if (lastCursorRow > 0)
1233
+ process.stdout.write(`\x1b[${lastCursorRow}A`);
1234
+ process.stdout.write("\r");
1219
1235
  dockSetFrame((prevFrameLines ?? []).join("\n"), lastTotalRows);
1220
1236
  dockEnterDocked();
1221
1237
  }
@@ -1243,15 +1259,21 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = [], hin
1243
1259
  process.stdout.write("\n");
1244
1260
  }
1245
1261
  prevFrameLines = newLines;
1246
- lastCursorRow = cursorRow;
1262
+ lastCursorRow = 0;
1247
1263
  lastTotalRows = totalRows;
1248
1264
  dockSetFrame(frame, totalRows);
1249
1265
  dockEnterDocked();
1250
- const up = totalRows - 1 - cursorRow;
1251
- process.stdout.write(`\x1b[${up}A\r\x1b[${cursorCol}C`);
1266
+ // Park the cursor at the box top so concurrent dock content (AI
1267
+ // streaming, tool results) inserts above the box rather than into it.
1268
+ const up = totalRows - 1;
1269
+ process.stdout.write(`\x1b[${up}A\r`);
1252
1270
  }
1253
1271
  else {
1254
- process.stdout.write(`\x1b[${lastCursorRow}A\r`);
1272
+ // Cursor is parked at the box top (row 0). Never emit \x1b[0A (which
1273
+ // terminals treat as "cursor up 1"): start from column 0, redraw only
1274
+ // the changed lines, then return the cursor to the box top so dock
1275
+ // content (AI streaming, tool results) keeps inserting above the box.
1276
+ process.stdout.write("\r");
1255
1277
  const oldLines = prevFrameLines ?? [];
1256
1278
  const maxLen = Math.max(oldLines.length, newLines.length);
1257
1279
  for (let i = 0; i < maxLen; i++) {
@@ -1261,14 +1283,11 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = [], hin
1261
1283
  if (i < maxLen - 1)
1262
1284
  process.stdout.write("\n");
1263
1285
  }
1264
- 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`);
1286
+ // Return to the box top (maxLen-1 rows up; never 0 since the frame has
1287
+ // at least a top border, body and bottom border).
1288
+ process.stdout.write(`\x1b[${maxLen - 1}A\r`);
1270
1289
  prevFrameLines = newLines;
1271
- lastCursorRow = cursorRow;
1290
+ lastCursorRow = 0;
1272
1291
  lastTotalRows = totalRows;
1273
1292
  dockSetFrame(frame, totalRows);
1274
1293
  }
@@ -1553,6 +1572,12 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = [], hin
1553
1572
  pasteBurstTimer = null;
1554
1573
  }
1555
1574
  if (key && key.ctrl && key.name === "c") {
1575
+ // While a query is running, Ctrl+C interrupts the query instead of
1576
+ // leaving the prompt (the box stays focused for editing).
1577
+ if (submitting && onInterrupt) {
1578
+ onInterrupt();
1579
+ return;
1580
+ }
1556
1581
  settle(new Error("interrupt"), true);
1557
1582
  return;
1558
1583
  }
@@ -1593,9 +1618,31 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = [], hin
1593
1618
  return;
1594
1619
  }
1595
1620
  if (isEnterKey(str, key)) {
1596
- if (buffer.trim()) {
1597
- settle([buffer, pasteSpans], false);
1621
+ const trimmed = buffer.trim();
1622
+ if (!trimmed)
1623
+ return;
1624
+ // While a query is running the box stays focused and editable; Enter is
1625
+ // ignored (even for commands) so the query finishes cleanly first.
1626
+ if (submitting)
1627
+ return;
1628
+ // With an onSubmit handler, ordinary prompts are submitted in place so
1629
+ // the box stays focused and editable while the query streams above it.
1630
+ // Slash commands (and any prompt when no handler is wired) settle so the
1631
+ // caller's existing command loop handles them.
1632
+ if (onSubmit && !trimmed.startsWith("/")) {
1633
+ submitting = true;
1634
+ const submitted = buffer;
1635
+ const submittedSpans = pasteSpans.slice();
1636
+ Promise.resolve()
1637
+ .then(() => onSubmit(submitted, submittedSpans))
1638
+ .then(() => {
1639
+ submitting = false;
1640
+ }, () => {
1641
+ submitting = false;
1642
+ });
1643
+ return;
1598
1644
  }
1645
+ settle([buffer, pasteSpans], false);
1599
1646
  return;
1600
1647
  }
1601
1648
  if (key && key.name === "backspace") {
@@ -1675,7 +1722,7 @@ export function userDisplayText(payload, pasteSpans) {
1675
1722
  let out = "";
1676
1723
  for (let i = 0; i < segs.length; i++) {
1677
1724
  const [, kind, disp] = segs[i];
1678
- if (i && !endsWithWs(segs[i - 1][2]))
1725
+ if (i && joinNeedsSpace(segs[i - 1]))
1679
1726
  out += " ";
1680
1727
  out += kind === "collapsed" ? `\x1b[1;36m${disp}\x1b[0m` : disp;
1681
1728
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxecli/oxe",
3
- "version": "1.0.88",
3
+ "version": "1.0.90",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },