@oxecli/oxe 1.0.89 → 1.0.91

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 +21 -42
  2. package/dist/ui.js +67 -67
  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, } 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, 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");
@@ -396,50 +396,14 @@ export class CLI {
396
396
  const used = estimateTokens(this.inputItems);
397
397
  const budget = max_context_tokens - context_overhead_margin;
398
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();
399
+ // Queries run the proven settle-on-Enter flow: the prompt settles and
400
+ // the query streams above it (robust dock streaming). While the query
401
+ // runs, typed keys are captured into a draft; the draft (or the sent
402
+ // prompt) is restored into the next prompt box so input is never lost.
439
403
  const [input, spans] = await askBottomPrompt("You", "❯", this.promptHistory, {
440
404
  left: "\x1b[90mPress \x1b[1;36mCtrl+C\x1b[0m\x1b[90m to interrupt\x1b[0m",
441
405
  right: `\x1b[1;36m${pct}%\x1b[0m \x1b[90muntil auto-compact\x1b[0m`,
442
- }, this.lastPrompt, onSubmit, onInterrupt);
406
+ }, this.lastPrompt);
443
407
  if (!input.trim())
444
408
  continue;
445
409
  this.interruptPending = false;
@@ -461,6 +425,7 @@ export class CLI {
461
425
  this.inputItems = [];
462
426
  this.story = [];
463
427
  this.sessionId = null;
428
+ this.lastPrompt = "";
464
429
  clearScreen();
465
430
  dockSetInactive();
466
431
  this.renderHeader();
@@ -491,6 +456,20 @@ export class CLI {
491
456
  }
492
457
  continue;
493
458
  }
459
+ dockAppendContent(`\x1b[1;36m❯\x1b[0m ${userDisplayText(input, spans)}\n`);
460
+ this.lastPrompt = input;
461
+ queryStarted = true;
462
+ const stopCapture = startDraftCapture(() => engine.interrupt());
463
+ try {
464
+ await engine.executeQuery(input, this.inputItems, this.story, spans);
465
+ }
466
+ finally {
467
+ const captured = stopCapture();
468
+ if (captured.trim())
469
+ this.lastPrompt = captured;
470
+ queryStarted = false;
471
+ }
472
+ this.saveSession(engine);
494
473
  }
495
474
  catch (err) {
496
475
  if (err?.message === "eof") {
package/dist/ui.js CHANGED
@@ -38,6 +38,52 @@ export function waitRawKey() {
38
38
  process.stdin.on("keypress", onKeypress);
39
39
  });
40
40
  }
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).
46
+ */
47
+ export function startDraftCapture(onInterrupt) {
48
+ let draft = "";
49
+ let active = true;
50
+ const onKey = (str, key) => {
51
+ if (!active)
52
+ return;
53
+ if (key && key.ctrl && key.name === "c") {
54
+ onInterrupt();
55
+ return;
56
+ }
57
+ if (key && key.ctrl && (key.name === "d" || key.name === "z"))
58
+ return;
59
+ if (key && key.name === "backspace") {
60
+ draft = draft.slice(0, -1);
61
+ return;
62
+ }
63
+ if (key && (key.name === "return" || key.name === "enter"))
64
+ return;
65
+ if (str && !key?.ctrl && !key?.meta) {
66
+ draft += str.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
67
+ }
68
+ };
69
+ readline.emitKeypressEvents(process.stdin);
70
+ if (process.stdin.isTTY)
71
+ process.stdin.setRawMode(true);
72
+ process.stdin.resume();
73
+ process.stdin.on("keypress", onKey);
74
+ return () => {
75
+ active = false;
76
+ process.stdin.removeListener("keypress", onKey);
77
+ try {
78
+ process.stdin.setRawMode(false);
79
+ }
80
+ catch {
81
+ /* ignore */
82
+ }
83
+ process.stdin.pause();
84
+ return draft;
85
+ };
86
+ }
41
87
  // ---------------------------------------------------------------------------
42
88
  // Terminal helpers
43
89
  // ---------------------------------------------------------------------------
@@ -1163,7 +1209,7 @@ function isEnterKey(str, key) {
1163
1209
  seq === "\x1bOM" // application keypad
1164
1210
  );
1165
1211
  }
1166
- export function askBottomPrompt(label = "You", prefix = "❯", history = [], hints, initialBuffer = "", onSubmit, onInterrupt) {
1212
+ export function askBottomPrompt(label = "You", prefix = "❯", history = [], hints, initialBuffer = "") {
1167
1213
  return new Promise((resolve, reject) => {
1168
1214
  const isTTY = process.stdin.isTTY;
1169
1215
  if (!isTTY) {
@@ -1192,7 +1238,6 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = [], hin
1192
1238
  readline.emitKeypressEvents(process.stdin);
1193
1239
  if (process.stdin.isTTY) {
1194
1240
  process.stdin.setRawMode(true);
1195
- // Bracketed paste so multi-line pastes arrive inside \x1b[200~…\x1b[201~.
1196
1241
  process.stdout.write("\x1b[?2004h");
1197
1242
  }
1198
1243
  process.stdin.resume();
@@ -1201,11 +1246,9 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = [], hin
1201
1246
  let lastCursorRow = 0;
1202
1247
  let lastTotalRows = 0;
1203
1248
  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;
1207
1249
  const clearBox = () => {
1208
- process.stdout.write(`\x1b[${lastCursorRow}A`);
1250
+ if (lastCursorRow > 0)
1251
+ process.stdout.write(`\x1b[${lastCursorRow}A`);
1209
1252
  for (let i = 0; i < lastTotalRows; i++) {
1210
1253
  process.stdout.write("\r\x1b[K");
1211
1254
  if (i < lastTotalRows - 1)
@@ -1228,7 +1271,9 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = [], hin
1228
1271
  dockSetInactive();
1229
1272
  }
1230
1273
  else {
1231
- process.stdout.write(`\x1b[${lastCursorRow}A\r`);
1274
+ if (lastCursorRow > 0)
1275
+ process.stdout.write(`\x1b[${lastCursorRow}A`);
1276
+ process.stdout.write("\r");
1232
1277
  dockSetFrame((prevFrameLines ?? []).join("\n"), lastTotalRows);
1233
1278
  dockEnterDocked();
1234
1279
  }
@@ -1256,14 +1301,12 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = [], hin
1256
1301
  process.stdout.write("\n");
1257
1302
  }
1258
1303
  prevFrameLines = newLines;
1259
- lastCursorRow = 0;
1304
+ lastCursorRow = cursorRow;
1260
1305
  lastTotalRows = totalRows;
1261
1306
  dockSetFrame(frame, totalRows);
1262
1307
  dockEnterDocked();
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`);
1308
+ const up = totalRows - 1 - cursorRow;
1309
+ process.stdout.write(`\x1b[${up}A\r\x1b[${cursorCol}C`);
1267
1310
  }
1268
1311
  else {
1269
1312
  process.stdout.write(`\x1b[${lastCursorRow}A\r`);
@@ -1277,11 +1320,13 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = [], hin
1277
1320
  process.stdout.write("\n");
1278
1321
  }
1279
1322
  const atLine = maxLen - 1;
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`);
1323
+ if (atLine > cursorRow)
1324
+ process.stdout.write(`\x1b[${atLine - cursorRow}A`);
1325
+ else if (atLine < cursorRow)
1326
+ process.stdout.write(`\x1b[${cursorRow - atLine}B`);
1327
+ process.stdout.write(`\r\x1b[${cursorCol}C`);
1283
1328
  prevFrameLines = newLines;
1284
- lastCursorRow = 0;
1329
+ lastCursorRow = cursorRow;
1285
1330
  lastTotalRows = totalRows;
1286
1331
  dockSetFrame(frame, totalRows);
1287
1332
  }
@@ -1314,8 +1359,6 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = [], hin
1314
1359
  }
1315
1360
  cursor += text.length;
1316
1361
  };
1317
- // A paste span is treated as a single atomic "block" only when it collapses
1318
- // to a marker (large pastes). Small inline pastes behave like normal text.
1319
1362
  const spanCollapsed = (span) => {
1320
1363
  return shouldCollapsePaste(buffer.slice(span[0], span[1]));
1321
1364
  };
@@ -1492,11 +1535,7 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = [], hin
1492
1535
  if (!bracketedPaste && (sequence === start || value === start)) {
1493
1536
  bracketedPaste = true;
1494
1537
  bracketedPasteBuffer = "";
1495
- const inline = value === start
1496
- ? ""
1497
- : value.startsWith(start)
1498
- ? value.slice(start.length)
1499
- : "";
1538
+ const inline = value === start ? "" : value.startsWith(start) ? value.slice(start.length) : "";
1500
1539
  if (inline)
1501
1540
  bracketedPasteBuffer = inline;
1502
1541
  if (inline.includes(end)) {
@@ -1516,9 +1555,7 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = [], hin
1516
1555
  if (!bracketedPaste)
1517
1556
  return false;
1518
1557
  if (sequence === end || value === end) {
1519
- const pasted = bracketedPasteBuffer
1520
- .replace(/\r\n/g, "\n")
1521
- .replace(/\r/g, "\n");
1558
+ const pasted = bracketedPasteBuffer.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
1522
1559
  bracketedPaste = false;
1523
1560
  bracketedPasteBuffer = "";
1524
1561
  if (pasted)
@@ -1526,15 +1563,12 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = [], hin
1526
1563
  repaint();
1527
1564
  return true;
1528
1565
  }
1529
- const chunk = value ||
1530
- (sequence && !sequence.startsWith("\x1b[") ? sequence : "");
1566
+ const chunk = value || (sequence && !sequence.startsWith("\x1b[") ? sequence : "");
1531
1567
  if (chunk) {
1532
1568
  const endAt = chunk.indexOf(end);
1533
1569
  if (endAt >= 0) {
1534
1570
  bracketedPasteBuffer += chunk.slice(0, endAt);
1535
- const pasted = bracketedPasteBuffer
1536
- .replace(/\r\n/g, "\n")
1537
- .replace(/\r/g, "\n");
1571
+ const pasted = bracketedPasteBuffer.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
1538
1572
  bracketedPaste = false;
1539
1573
  bracketedPasteBuffer = "";
1540
1574
  if (pasted)
@@ -1566,12 +1600,6 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = [], hin
1566
1600
  pasteBurstTimer = null;
1567
1601
  }
1568
1602
  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
- }
1575
1603
  settle(new Error("interrupt"), true);
1576
1604
  return;
1577
1605
  }
@@ -1612,31 +1640,9 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = [], hin
1612
1640
  return;
1613
1641
  }
1614
1642
  if (isEnterKey(str, key)) {
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;
1643
+ if (buffer.trim()) {
1644
+ settle([buffer, pasteSpans], false);
1638
1645
  }
1639
- settle([buffer, pasteSpans], false);
1640
1646
  return;
1641
1647
  }
1642
1648
  if (key && key.name === "backspace") {
@@ -1689,9 +1695,6 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = [], hin
1689
1695
  repaint();
1690
1696
  return;
1691
1697
  }
1692
- // Ctrl+J sends a bare LF (0x0a). Node reports it as name "enter" or
1693
- // "linefeed". It is not a real Enter (a real Enter arrives as CR / name
1694
- // "return"), so ignore it rather than submitting or inserting a newline.
1695
1698
  if (key && (key.name === "enter" || key.name === "linefeed")) {
1696
1699
  return;
1697
1700
  }
@@ -1707,9 +1710,6 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = [], hin
1707
1710
  repaint(true);
1708
1711
  });
1709
1712
  }
1710
- // ---------------------------------------------------------------------------
1711
- // User Display & Message Text Helpers
1712
- // ---------------------------------------------------------------------------
1713
1713
  export function userDisplayText(payload, pasteSpans) {
1714
1714
  if (pasteSpans && pasteSpans.length) {
1715
1715
  const segs = splitBlocks(payload, pasteSpans);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxecli/oxe",
3
- "version": "1.0.89",
3
+ "version": "1.0.91",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },