@oxecli/oxe 1.0.90 → 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 +64 -70
  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,9 +1246,6 @@ 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
1250
  if (lastCursorRow > 0)
1209
1251
  process.stdout.write(`\x1b[${lastCursorRow}A`);
@@ -1259,21 +1301,15 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = [], hin
1259
1301
  process.stdout.write("\n");
1260
1302
  }
1261
1303
  prevFrameLines = newLines;
1262
- lastCursorRow = 0;
1304
+ lastCursorRow = cursorRow;
1263
1305
  lastTotalRows = totalRows;
1264
1306
  dockSetFrame(frame, totalRows);
1265
1307
  dockEnterDocked();
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`);
1308
+ const up = totalRows - 1 - cursorRow;
1309
+ process.stdout.write(`\x1b[${up}A\r\x1b[${cursorCol}C`);
1270
1310
  }
1271
1311
  else {
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");
1312
+ process.stdout.write(`\x1b[${lastCursorRow}A\r`);
1277
1313
  const oldLines = prevFrameLines ?? [];
1278
1314
  const maxLen = Math.max(oldLines.length, newLines.length);
1279
1315
  for (let i = 0; i < maxLen; i++) {
@@ -1283,11 +1319,14 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = [], hin
1283
1319
  if (i < maxLen - 1)
1284
1320
  process.stdout.write("\n");
1285
1321
  }
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`);
1322
+ const atLine = maxLen - 1;
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`);
1289
1328
  prevFrameLines = newLines;
1290
- lastCursorRow = 0;
1329
+ lastCursorRow = cursorRow;
1291
1330
  lastTotalRows = totalRows;
1292
1331
  dockSetFrame(frame, totalRows);
1293
1332
  }
@@ -1320,8 +1359,6 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = [], hin
1320
1359
  }
1321
1360
  cursor += text.length;
1322
1361
  };
1323
- // A paste span is treated as a single atomic "block" only when it collapses
1324
- // to a marker (large pastes). Small inline pastes behave like normal text.
1325
1362
  const spanCollapsed = (span) => {
1326
1363
  return shouldCollapsePaste(buffer.slice(span[0], span[1]));
1327
1364
  };
@@ -1498,11 +1535,7 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = [], hin
1498
1535
  if (!bracketedPaste && (sequence === start || value === start)) {
1499
1536
  bracketedPaste = true;
1500
1537
  bracketedPasteBuffer = "";
1501
- const inline = value === start
1502
- ? ""
1503
- : value.startsWith(start)
1504
- ? value.slice(start.length)
1505
- : "";
1538
+ const inline = value === start ? "" : value.startsWith(start) ? value.slice(start.length) : "";
1506
1539
  if (inline)
1507
1540
  bracketedPasteBuffer = inline;
1508
1541
  if (inline.includes(end)) {
@@ -1522,9 +1555,7 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = [], hin
1522
1555
  if (!bracketedPaste)
1523
1556
  return false;
1524
1557
  if (sequence === end || value === end) {
1525
- const pasted = bracketedPasteBuffer
1526
- .replace(/\r\n/g, "\n")
1527
- .replace(/\r/g, "\n");
1558
+ const pasted = bracketedPasteBuffer.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
1528
1559
  bracketedPaste = false;
1529
1560
  bracketedPasteBuffer = "";
1530
1561
  if (pasted)
@@ -1532,15 +1563,12 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = [], hin
1532
1563
  repaint();
1533
1564
  return true;
1534
1565
  }
1535
- const chunk = value ||
1536
- (sequence && !sequence.startsWith("\x1b[") ? sequence : "");
1566
+ const chunk = value || (sequence && !sequence.startsWith("\x1b[") ? sequence : "");
1537
1567
  if (chunk) {
1538
1568
  const endAt = chunk.indexOf(end);
1539
1569
  if (endAt >= 0) {
1540
1570
  bracketedPasteBuffer += chunk.slice(0, endAt);
1541
- const pasted = bracketedPasteBuffer
1542
- .replace(/\r\n/g, "\n")
1543
- .replace(/\r/g, "\n");
1571
+ const pasted = bracketedPasteBuffer.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
1544
1572
  bracketedPaste = false;
1545
1573
  bracketedPasteBuffer = "";
1546
1574
  if (pasted)
@@ -1572,12 +1600,6 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = [], hin
1572
1600
  pasteBurstTimer = null;
1573
1601
  }
1574
1602
  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
- }
1581
1603
  settle(new Error("interrupt"), true);
1582
1604
  return;
1583
1605
  }
@@ -1618,31 +1640,9 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = [], hin
1618
1640
  return;
1619
1641
  }
1620
1642
  if (isEnterKey(str, key)) {
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;
1643
+ if (buffer.trim()) {
1644
+ settle([buffer, pasteSpans], false);
1644
1645
  }
1645
- settle([buffer, pasteSpans], false);
1646
1646
  return;
1647
1647
  }
1648
1648
  if (key && key.name === "backspace") {
@@ -1695,9 +1695,6 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = [], hin
1695
1695
  repaint();
1696
1696
  return;
1697
1697
  }
1698
- // Ctrl+J sends a bare LF (0x0a). Node reports it as name "enter" or
1699
- // "linefeed". It is not a real Enter (a real Enter arrives as CR / name
1700
- // "return"), so ignore it rather than submitting or inserting a newline.
1701
1698
  if (key && (key.name === "enter" || key.name === "linefeed")) {
1702
1699
  return;
1703
1700
  }
@@ -1713,9 +1710,6 @@ export function askBottomPrompt(label = "You", prefix = "❯", history = [], hin
1713
1710
  repaint(true);
1714
1711
  });
1715
1712
  }
1716
- // ---------------------------------------------------------------------------
1717
- // User Display & Message Text Helpers
1718
- // ---------------------------------------------------------------------------
1719
1713
  export function userDisplayText(payload, pasteSpans) {
1720
1714
  if (pasteSpans && pasteSpans.length) {
1721
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.90",
3
+ "version": "1.0.91",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },